From 30da3f8b4e056ce20f5e9f87a6158489b4c60d55 Mon Sep 17 00:00:00 2001 From: BillyOutlast <172061051+BillyOutlast@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:41:35 -0400 Subject: [PATCH] Surgical conversion: Tier-1 patcher + launcher hook, Tier-2 extraction (Phases 1-5) Replaces the 205-file fork delivery vehicle with patches/ + launcher/ + versioned packages, per the implementation plan: Phase 1: seam tripwires (27 tests) freezing every surgical target in upstream (Usage predicate, %16 gate, SubscriptionChecker, debugger, LoadingScreen branding, Electron hook surface, no-plugin-registry verdict). Phase 2: 5 generated Tier-1 hunks (round-trip verified; 3 files reach byte-identical fork parity) + apply driver (dry-run gate, marker verify, asar extract/repack). Phase 3: launcher hook (preload fetch/XHR spoof of the two usage GETs, BrowserWindow wrap, webRequest fallback, updater pin) + 06 loader patch. Module._load interception dropped: renderers run a webpack bundle, so the spoof rides the axios/XHR path the provider hydrates from instead. Phase 4: Tier-2 verbatim extraction (CustomAIClient + 12 Studio modules + offline mock harness, manifest-pinned) + 07/08 verbatim glue patches + pure generation shim with fail-closed leak guard. Phase 5: web userscript demo (Tier-1 only, documented CORS/LAN/churn limits). Phase 6 runbook + upstream provider-interface RFC draft included; fork archiving left to owners (destructive). Suite: 66 tests green (seams, patcher incl. re-apply refusal, launcher incl. vm-sandbox fallback eval, tier-2 provenance, web). --- .github/workflows/seam-tests.yml | 31 + .gitignore | 8 + CLAUDE_CODE_RESEARCH_PROMPT.md | 180 + RETIREMENT.md | 54 + UPSTREAM_BASELINE.json | 15 + docs/upstream-ai-provider-rfc.md | 65 + launcher/README.md | 55 + launcher/heretek-hook.cjs | 263 + launcher/preload-unlock.cjs | 268 + launcher/unlock-payloads.cjs | 141 + package.json | 20 + packages/ai-studio/AI/CustomAIClient.js | 5870 +++++++++++++ packages/ai-studio/AI/CustomAIClient.spec.js | 7266 +++++++++++++++++ .../AiGeneration/Studio/EditApprovalLabel.js | 34 + .../AiGeneration/Studio/FinalizeSubAgents.js | 262 + .../AiGeneration/Studio/LoopGuard.js | 276 + .../AiGeneration/Studio/NudgePolicy.js | 95 + .../AiGeneration/Studio/PlanStore.js | 399 + .../AiGeneration/Studio/RequestWriteGate.js | 176 + .../AiGeneration/Studio/RoleToolPolicy.js | 53 + .../ai-studio/AiGeneration/Studio/Roles.js | 270 + .../AiGeneration/Studio/SafeTruncation.js | 68 + .../AiGeneration/Studio/SpawnSubAgents.js | 277 + .../AiGeneration/Studio/UseStudioNudge.js | 159 + .../AiGeneration/Studio/UseStudioRuntime.js | 302 + packages/ai-studio/MANIFEST.json | 100 + packages/ai-studio/README.md | 40 + .../ai-studio/dev-harness/mock-ai-provider.js | 289 + .../mock-child-scripts.example.json | 23 + .../dev-harness/mock-script-multi.json | 33 + .../dev-harness/mock-script-specialists.json | 24 + .../dev-harness/mock-script.example.json | 40 + packages/unlock-shim/README.md | 20 + packages/unlock-shim/generation-shim.mjs | 92 + patches/01-usage-predicate.patch | 74 + patches/02-auth-context-seed.patch | 57 + patches/03-subscription-checker.patch | 78 + patches/04-loading-screen.patch | 73 + patches/05-preview-debugger.patch | 46 + patches/06-electron-hook.patch | 19 + patches/07-generation-glue.patch | 713 ++ patches/08-preferences-ai.patch | 760 ++ patches/README.md | 39 + scripts/apply-patches.mjs | 387 + scripts/extract-tier2.mjs | 88 + scripts/generate-tier1-patches.mjs | 490 ++ scripts/generate-tier2-patches.mjs | 103 + tests/launcher/hook.test.cjs | 188 + tests/launcher/payloads.test.cjs | 78 + tests/launcher/preload.test.cjs | 171 + tests/patches/apply.test.mjs | 118 + tests/seams/debugger.seam.test.mjs | 55 + tests/seams/electron.seam.test.mjs | 138 + tests/seams/helpers.mjs | 108 + tests/seams/loadingscreen.seam.test.mjs | 76 + tests/seams/preview.seam.test.mjs | 56 + .../seams/subscription-checker.seam.test.mjs | 59 + tests/seams/usage.seam.test.mjs | 61 + tests/tier2/extract.test.mjs | 99 + tests/tier2/shim.test.mjs | 98 + tests/tier2/tier2-patches.test.mjs | 103 + tests/web/userscript.test.mjs | 47 + web-userscript/README.md | 35 + web-userscript/heretek-unlock.user.js | 190 + 64 files changed, 21875 insertions(+) create mode 100644 .github/workflows/seam-tests.yml create mode 100644 .gitignore create mode 100644 CLAUDE_CODE_RESEARCH_PROMPT.md create mode 100644 RETIREMENT.md create mode 100644 UPSTREAM_BASELINE.json create mode 100644 docs/upstream-ai-provider-rfc.md create mode 100644 launcher/README.md create mode 100644 launcher/heretek-hook.cjs create mode 100644 launcher/preload-unlock.cjs create mode 100644 launcher/unlock-payloads.cjs create mode 100644 package.json create mode 100644 packages/ai-studio/AI/CustomAIClient.js create mode 100644 packages/ai-studio/AI/CustomAIClient.spec.js create mode 100644 packages/ai-studio/AiGeneration/Studio/EditApprovalLabel.js create mode 100644 packages/ai-studio/AiGeneration/Studio/FinalizeSubAgents.js create mode 100644 packages/ai-studio/AiGeneration/Studio/LoopGuard.js create mode 100644 packages/ai-studio/AiGeneration/Studio/NudgePolicy.js create mode 100644 packages/ai-studio/AiGeneration/Studio/PlanStore.js create mode 100644 packages/ai-studio/AiGeneration/Studio/RequestWriteGate.js create mode 100644 packages/ai-studio/AiGeneration/Studio/RoleToolPolicy.js create mode 100644 packages/ai-studio/AiGeneration/Studio/Roles.js create mode 100644 packages/ai-studio/AiGeneration/Studio/SafeTruncation.js create mode 100644 packages/ai-studio/AiGeneration/Studio/SpawnSubAgents.js create mode 100644 packages/ai-studio/AiGeneration/Studio/UseStudioNudge.js create mode 100644 packages/ai-studio/AiGeneration/Studio/UseStudioRuntime.js create mode 100644 packages/ai-studio/MANIFEST.json create mode 100644 packages/ai-studio/README.md create mode 100644 packages/ai-studio/dev-harness/mock-ai-provider.js create mode 100644 packages/ai-studio/dev-harness/mock-child-scripts.example.json create mode 100644 packages/ai-studio/dev-harness/mock-script-multi.json create mode 100644 packages/ai-studio/dev-harness/mock-script-specialists.json create mode 100644 packages/ai-studio/dev-harness/mock-script.example.json create mode 100644 packages/unlock-shim/README.md create mode 100644 packages/unlock-shim/generation-shim.mjs create mode 100644 patches/01-usage-predicate.patch create mode 100644 patches/02-auth-context-seed.patch create mode 100644 patches/03-subscription-checker.patch create mode 100644 patches/04-loading-screen.patch create mode 100644 patches/05-preview-debugger.patch create mode 100644 patches/06-electron-hook.patch create mode 100644 patches/07-generation-glue.patch create mode 100644 patches/08-preferences-ai.patch create mode 100644 patches/README.md create mode 100644 scripts/apply-patches.mjs create mode 100644 scripts/extract-tier2.mjs create mode 100644 scripts/generate-tier1-patches.mjs create mode 100644 scripts/generate-tier2-patches.mjs create mode 100644 tests/launcher/hook.test.cjs create mode 100644 tests/launcher/payloads.test.cjs create mode 100644 tests/launcher/preload.test.cjs create mode 100644 tests/patches/apply.test.mjs create mode 100644 tests/seams/debugger.seam.test.mjs create mode 100644 tests/seams/electron.seam.test.mjs create mode 100644 tests/seams/helpers.mjs create mode 100644 tests/seams/loadingscreen.seam.test.mjs create mode 100644 tests/seams/preview.seam.test.mjs create mode 100644 tests/seams/subscription-checker.seam.test.mjs create mode 100644 tests/seams/usage.seam.test.mjs create mode 100644 tests/tier2/extract.test.mjs create mode 100644 tests/tier2/shim.test.mjs create mode 100644 tests/tier2/tier2-patches.test.mjs create mode 100644 tests/web/userscript.test.mjs create mode 100644 web-userscript/README.md create mode 100644 web-userscript/heretek-unlock.user.js diff --git a/.github/workflows/seam-tests.yml b/.github/workflows/seam-tests.yml new file mode 100644 index 0000000..ddb2479 --- /dev/null +++ b/.github/workflows/seam-tests.yml @@ -0,0 +1,31 @@ +name: seam-tests + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: '0 6 * * 1' # weekly drift check against upstream baseline + +jobs: + seams: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + # Baselines are gitignored locally (multi-GB); CI clones the pins: + # upstream verified-at commit + fork donor baseline (provenance tests). + - name: Clone pinned upstream baseline + run: | + git clone --no-checkout https://github.com/4ian/GDevelop review/GDevelop + git -C review/GDevelop checkout 8301cdcaa48b496f48a28a6e3a56fa833eaeddff + - name: Clone fork donor baseline + run: | + git clone --no-checkout https://github.com/Heretek-AI/GDevelop-Heretek review/GDevelop-Heretek + git -C review/GDevelop-Heretek checkout b9109cfbaaaf19c31b5af4d315317fb67d8903c3 + - name: Run full suite (seams + patcher + launcher + tier-2 + web) + run: npm test + - name: Patcher gate (seams + patch inventory) + run: node scripts/apply-patches.mjs --check diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6e0bd6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Local-only upstream/fork checkouts (multi-GB baselines for seam + +# provenance tests). CI clones the pinned commits (see UPSTREAM_BASELINE.json +# and packages/ai-studio/MANIFEST.json); never commit these. +review/ +node_modules/ +coverage/ +*.log +.DS_Store diff --git a/CLAUDE_CODE_RESEARCH_PROMPT.md b/CLAUDE_CODE_RESEARCH_PROMPT.md new file mode 100644 index 0000000..ef27749 --- /dev/null +++ b/CLAUDE_CODE_RESEARCH_PROMPT.md @@ -0,0 +1,180 @@ +# CLAUDE CODE RESEARCH PROMPT: GDEVELOP PLUGIN AUDIT & HERETEK SURGICAL CONVERSION + +> **How to Use**: Copy and paste the entire prompt below directly into **Claude Code** (or run `claude` inside `/home/john/Projects/GDevelop-Unlocked` and pass this file). + +*** + +# TASK: Comprehensive Technical Audit & Surgical Conversion Feasibility Study (GDevelop vs. GDevelop-Heretek) + +## Context & Repository Locations +You are tasked with conducting an exhaustive technical audit of two local repositories: +1. **Upstream GDevelop**: `/home/john/Projects/GDevelop-Unlocked/review/GDevelop` + - Upstream GitHub: https://github.com/4ian/GDevelop + - The official open-source 2D/3D no-code game engine (Core C++, Emscripten/Wasm `GDevelop.js`, Pixi/Three runtime `GDJS`, and React/Electron `newIDE`). +2. **GDevelop-Heretek (BYOK Edition)**: `/home/john/Projects/GDevelop-Unlocked/review/GDevelop-Heretek` + - Fork GitHub: https://github.com/Heretek-AI/GDevelop-Heretek + - A fork maintaining daily syncs with upstream that unlocks premium subscription features and adds a Bring-Your-Own-Key (BYOK) local AI client and multi-agent game studio. + - Reference documentation in fork: `AGENTS.md`, `fork-divergence.json`, `MAINTENANCE.md`, and README feature comparison. + +--- + +## Core Mission +Analyze whether maintaining an upstream-diverging 205-file fork can be replaced by a **surgical conversion** (e.g., an external plugin, Electron runtime injector, AST/codemod patcher, or local proxy), enabling users to run vanilla upstream GDevelop while unlocking all features listed in the Heretek Feature Comparison table without maintaining a massive fork. + +--- + +## Phase 1: Upstream Architecture & Plugin Capability Audit + +Investigate `/home/john/Projects/GDevelop-Unlocked/review/GDevelop`: +1. **Disambiguate Engine Extensions vs. IDE Plugins:** + - Examine `review/GDevelop/Extensions/` and `review/GDevelop/GDJS/Runtime/`. + - Confirm whether "Extensions" in GDevelop strictly apply to the **game engine runtime** (`gdjs.RuntimeBehavior`, `gdjs.RuntimeObject`, actions, conditions, and Pixi/Three renderers) or if any extension points exist for the editor itself. +2. **Audit `newIDE/app` (The React/Flow Editor):** + - Search for any dynamic module loading, plugin registries, extension hooks, or lifecycle events in `newIDE/app/src/`. + - Inspect `newIDE/app/src/index.js`, `LocalApp.js`, `BrowserApp.js`, and `MainFrame/index.js`. + - Does `newIDE` have any native plugin or add-on architecture for third-party UI components, custom tabs, or service overrides? +3. **Audit `newIDE/electron-app` (The Desktop Shell):** + - Inspect `newIDE/electron-app/app/main.js` and `Utils/UrlLoader.js`. + - Analyze Electron security and configuration settings: + - Notice `webPreferences.nodeIntegration`, `contextIsolation`, and `webSecurity`. + - How is the renderer page loaded (`file://`, `gdide://`, or dev server)? + - Are preload scripts utilized or can arbitrary preload scripts be attached? + - What IPC channels exist between renderer and main? + +--- + +## Phase 2: Feature-by-Feature Entitlement Dissection + +Compare `/home/john/Projects/GDevelop-Unlocked/review/GDevelop` with `/home/john/Projects/GDevelop-Unlocked/review/GDevelop-Heretek`. +Audit the exact implementation of every feature listed in the Heretek Feature Comparison table: + +### 1. Watermark, Startup Logo, & Splash Duration Clamps +- Inspect `newIDE/app/src/ProjectManager/LoadingScreenEditor.js`. +- Contrast how upstream gates: + - `loadingScreen.isGDevelopLogoShownDuringLoadingScreen()` + - `watermark.isGDevelopWatermarkShown()` + - `loadingScreen.setMinDuration()` / `forcedLogo.minDuration` +- Identify the exact boolean checks that guard these options. + +### 2. Live Preview (Hot Reloading) & Wi-Fi / LAN Preview +- Inspect `newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/index.js`. +- Analyze: + - The paywall popup triggered every 16 hot reloads (`this.state.hotReloadsCount % 16 === 0`). + - Network preview gating (`_checkSubscriptionForNetworkPreview`). +- How does Heretek neutralize these checks? + +### 3. In-Game Debugger & Profiler +- Inspect `newIDE/app/src/MainFrame/EditorContainers/DebuggerEditorContainer.js`. +- Analyze `_checkUserHasSubscription()` and how upstream restricts the debugger tab. + +### 4. Entitlements & Subscription Architecture +- Inspect `newIDE/app/src/Utils/GDevelopServices/Usage.js`: + - Analyze `hasValidSubscriptionPlan()`, `Subscription` type, and `Capabilities` object. +- Inspect `newIDE/app/src/Profile/AuthenticatedUserContext.js` and `AuthenticatedUserProvider.js`: + - Analyze `UNLOCKED_HERETEK_SUBSCRIPTION` and `UNLOCKED_HERETEK_CAPABILITIES`. + - Contrast how upstream populates user capabilities from backend APIs vs. how Heretek seeds default unlocked capabilities. + +### 5. AI Assistant & Game Creator (BYOK & Local LLMs) +- Inspect `newIDE/app/src/AI/CustomAIClient.js` (~5,870 lines) in Heretek: + - Analyze the OpenAI-compatible client, tool schemas (`GDEVELOP_OPENAI_TOOLS`), SSE streaming, and local storage keys (`gd-custom-ai-config`, `gd-custom-ai-requests`). +- Inspect `newIDE/app/src/Utils/GDevelopServices/Generation.js`: + - How does Heretek hook into `Generation.js` (`isCustomEndpointEnabled()`, `customCreateAiRequest()`)? +- Inspect `newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js`: + - How is the `'ai'` configuration tab injected into the preferences modal? + +### 6. Multi-Agent AI Studio (`AiGeneration/Studio/`) +- Inspect `newIDE/app/src/AiGeneration/Studio/` in Heretek (~15 files): + - Review `Roles.js`, `PlanStore.js`, `LoopGuard.js`, `SpawnSubAgents.js`, `FinalizeSubAgents.js`, `UseStudioRuntime.js`. + - Determine how deeply these files couple with the rest of the editor vs. operating as modular self-contained logic. + +### 7. Local HTML5 & Desktop Exports +- Audit export limits in `Usage.js` and `ExportAndShare/` to determine if any local build operations are restricted in upstream. + +--- + +## Phase 3: Architectural Feasibility of Surgical Conversion + +Evaluate the feasibility, pros, cons, and implementation complexity of converting Heretek's features into an external solution across these 5 technical vectors: + +### Vector A: Electron Preload / Runtime Hook Injection (The "BetterDiscord / Spicetify" Model) +- **Mechanics**: + - Because upstream Electron sets `nodeIntegration: true` and `contextIsolation: false`, any code running in the renderer has unrestricted access to the Node runtime, DOM, and React component tree. + - Can an external launcher inject a custom script via: + 1. An Electron preload argument / CLI flag (`--require `). + 2. Modifying `newIDE/electron-app/app/main.js` (or `app.asar`) with a single-line hook loader. + 3. A standalone wrapper launcher executing Electron with a custom bootstrap script. +- **Feasibility Questions**: + - Can React Context (`AuthenticatedUserContext`, `PreferencesContext`) be intercepted or patched at window load before components render? + - Can `Usage.hasValidSubscriptionPlan` or Axios interceptors be monkey-patched at runtime? + - How could the BYOK AI settings tab and custom AI client be injected into React DOM/Context from an external script? + +### Vector B: Local Reverse Proxy / Network Mocking Layer +- **Mechanics**: + - GDevelop's subscription checks and AI generation requests communicate with `api.gdevelop.io` and Firebase. + - Can a lightweight local HTTP proxy (or Electron `session.webRequest` / protocol interceptor) intercept: + 1. Requests to `/usage` or `/user` and return simulated Gold/Enterprise subscription payloads. + 2. Requests to `/ai-request` and translate GDevelop AI format into OpenAI / Ollama format directly on the network level? +- **Feasibility Questions**: + - Does spoofing network responses completely satisfy the client-side UI checks (e.g. `LoadingScreenEditor`, `LocalPreviewLauncher`), or do those checks evaluate local React state regardless of network responses? + - Can the entire multi-agent studio loop function via network proxy alone, or does it require deep editor DOM/state integration? + +### Vector C: AST / Codemod / Patcher Engine (The "patch-package" Model) +- **Mechanics**: + - Instead of maintaining a full git repository with daily upstream merge commits, maintain a directory of atomic diff patches (or Babel / `jscodeshift` AST transforms). + - A CLI tool (`gdevelop-unlocked patch`) unpacks upstream `app.asar` (or runs post-install in `newIDE/`), applies the surgical transforms, and repacks. +- **Feasibility Questions**: + - How fragile are the AST patches against upstream code updates? + - Contrast the maintenance burden of updating AST transforms vs. resolving git merge conflicts across 205 files in `fork-divergence.json`. + +### Vector D: Browser Userscript / Extension (Web Editor Support) +- **Mechanics**: + - For `editor.gdevelop.io`, analyze whether a Userscript (Tampermonkey/Violentmonkey) or Chrome extension can hook into the Webpack chunk registry (`window.webpackChunk...`), patch React state, intercept `fetch`/`XMLHttpRequest`, and inject BYOK AI. +- **Feasibility Questions**: + - What are the limitations in the web version compared to Electron (e.g., local network preview, Ollama CORS)? + +### Vector E: Upstream Plugin Architecture PR +- **Mechanics**: + - Assess whether an RFC / PR proposing a clean IDE Plugin API to 4ian/GDevelop would be technically viable and whether upstream's commercial model makes it unlikely to be accepted. + +--- + +## Phase 4: Separation of Concerns (Two-Tier Architecture) + +Notice that Heretek's changes divide sharply into two categories: +1. **Tier 1: Entitlement Unlocks (<50 lines across ~5 files):** + - Removing paywalls, logos, watermarks, splash delays, hot-reload restrictions. +2. **Tier 2: BYOK AI & Multi-Agent Studio (~8,000+ lines):** + - Full LLM orchestration, local endpoint support, multi-agent task runner. + +Evaluate: +- Should Tier 1 and Tier 2 be decoupled into separate modules (e.g., a lightweight 100-line "Entitlement Unlocker" and a modular "AI Studio Addon")? +- How does decoupling affect maintenance, legal risk, and user flexibility? + +--- + +## Deliverables Required in Your Report + +Provide a clear, detailed, and technically rigorous report containing: + +1. **Executive Summary**: Core verdict on whether a plugin/injector can replace the fork. +2. **Upstream Architecture Audit**: Official findings on GDevelop's extension points (runtime vs. IDE). +3. **Entitlement Mechanism Map**: Complete table mapping each feature to: + - Upstream file path and line numbers + - Gating logic (how upstream locks it) + - Heretek unlock logic (how Heretek bypassed it) + - Surgical injection vector (how to bypass it without source modification) +4. **Injection Vector Comparison Matrix**: + - Compare Vector A (Electron Preload Hook), Vector B (Network Proxy), Vector C (AST/Patcher), and Vector D (Userscript) on: + - Feasibility (High/Med/Low) + - Upstream update resilience (fragility) + - Implementation complexity + - Support for Desktop vs. Web + - Support for Tier 1 (Unlocks) vs. Tier 2 (BYOK AI) +5. **Recommended Architecture & Design Blueprint**: + - Detailed specification of the recommended approach. + - Proposed file structure for a standalone `gdevelop-unlocked` / `gdevelop-plugin` project. + - Exact code snippets demonstrating: + - How to hook Electron window creation or preload. + - How to patch `hasValidSubscriptionPlan` and `AuthenticatedUserContext`. + - How to mount the BYOK AI settings UI and redirect generation calls. +6. **Actionable Implementation Roadmap**: Step-by-step phases to build, test, and transition away from the fork. diff --git a/RETIREMENT.md b/RETIREMENT.md new file mode 100644 index 0000000..9001e93 --- /dev/null +++ b/RETIREMENT.md @@ -0,0 +1,54 @@ +# Phase 6: Decommission fork — retirement runbook (issue #6) + +The 205-file fork (`Heretek-AI/GDevelop-Heretek`) stops being the +delivery vehicle and stays only as the Tier-2 donor archive. Nothing +here is automated: archiving and workflow deletion are destructive and +need a human owner. Do not close issue #6 until every box is ticked. + +## Preconditions (M1 + M2 must hold without the fork present) + +- [ ] `npm test` green with the fork checkout absent + (`FORK_DIR` pointed at an empty dir — only `tests/tier2/extract.test.mjs` + needs the donor, and only to re-verify byte-identity). +- [ ] `node scripts/apply-patches.mjs --target ` green + (all 8 patches + launcher sync + marker verify). +- [ ] Desktop smoke on the patched build: logo uncheck, watermark + uncheck, hot reload x17, wifi preview, debugger open (issue #2). +- [ ] Desktop E2E: `http://localhost:11434/v1` generation + subagent + spawn/finalize loop, zero `api.gdevelop.io/generation` calls in + BYOK mode (issue #4, procedure in `packages/ai-studio/README.md`). + +## Retirement steps (human, in order) + +1. Delete the daily sync: remove `.github/workflows/upstream-sync.yml` + from the fork (last sync first — record its commit hash below). +2. Mark the fork read-only: repository Settings → Archive, with the + description pointing here: + `Retired as delivery vehicle; Tier-2 donor archive for + Heretek-AI/GDevelop-Unlocked. Donor commit b9109cfbaaaf19c31b5af4d315317fb67d8903c3.` +3. After archiving, Tier-1 ships only as `patches/` + `launcher/` + (Phases 2–3); Tier-2 only as versioned `packages/ai-studio` + (Phase 4). Any future fork-sync need is a `scripts/extract-tier2.mjs` + re-run + `scripts/generate-tier2-patches.mjs` regeneration, reviewed + as a normal PR — never a merge treadmill. + +```bash +# 1. (in the fork checkout, final state) +git log -1 --format=%H # record as last-sync hash: ________________ +gh workflow disable upstream-sync.yml --repo Heretek-AI/GDevelop-Heretek +# 2. (owner only) +gh repo archive Heretek-AI/GDevelop-Heretek --yes +``` + +## Post-retirement invariant + +The delivery path (patcher, hook, preload, shim, userscript) never +*reads* the fork: `GDevelop-Heretek` appears in delivery code only in +provenance comments (`Unlocked in GDevelop-Heretek`) and in donor-dir +defaults owned by the extractor (`scripts/extract-tier2.mjs`, +`scripts/generate-tier2-patches.mjs`) and the two provenance tests +(`tests/tier2/extract.test.mjs`, `tests/tier2/tier2-patches.test.mjs`), +which fail loudly — by design — when the donor is absent. Everything +else in `npm test` is fork-free (verified: 63 pass with `FORK_DIR` +pointed at an empty dir; the 1 failure + 2 cancellations are the +donor-provenance tests failing loudly by design — not silent drift). diff --git a/UPSTREAM_BASELINE.json b/UPSTREAM_BASELINE.json new file mode 100644 index 0000000..8de7d38 --- /dev/null +++ b/UPSTREAM_BASELINE.json @@ -0,0 +1,15 @@ +{ + "comment": "Pinned upstream shape the Phase 1 seam tests were written against. CI re-runs the suite against this commit on schedule; any red suite means a patch hunk or hook target moved.", + "donorBaselineCommit": "b9109cfbaaaf19c31b5af4d315317fb67d8903c3", + "verifiedAtUpstreamCommit": "8301cdcaa4", + "verifiedAt": "2026-10-11", + "seams": { + "Usage.hasValidSubscriptionPlan": "newIDE/app/src/Utils/GDevelopServices/Usage.js:~605", + "LocalPreviewLauncher.%16": "newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/index.js:~441", + "SubscriptionChecker": "newIDE/app/src/Profile/Subscription/SubscriptionChecker.js:~66", + "DebuggerEditorContainer": "newIDE/app/src/MainFrame/EditorContainers/DebuggerEditorContainer.js:~135", + "LoadingScreenEditor": "newIDE/app/src/ProjectManager/LoadingScreenEditor.js:~106,~162", + "electron.main.webPreferences": "newIDE/electron-app/app/main.js:~174", + "electron.UrlLoader": "newIDE/electron-app/app/Utils/UrlLoader.js" + } +} diff --git a/docs/upstream-ai-provider-rfc.md b/docs/upstream-ai-provider-rfc.md new file mode 100644 index 0000000..40b1ee4 --- /dev/null +++ b/docs/upstream-ai-provider-rfc.md @@ -0,0 +1,65 @@ +# RFC draft: provider-agnostic AI interface for upstream GDevelop (Vector E) + +Status: **draft for diplomacy, not on any critical path** (issue #7). +If rejected, close as wontfix-with-notes — M1/M2 delivery is unaffected +either way. This proposes an interface only. It pointedly does NOT +propose paywall-disable hooks, entitlement overrides, or any change to +subscription/credit monetization. + +## Problem + +The IDE's AI surface (`Utils/GDevelopServices/Generation.js`, +`AiGeneration/`) is hard-wired to the hosted generation API. There is +no seam where an alternative completion provider (self-hosted gateway, +enterprise proxy, local OpenAI-compatible server) can participate — +so BYOK-style integrations can only exist as forks. + +## Proposal: `AiProvider` interface + +A minimal provider contract, implemented once for hosted and open for +alternatives: + +```ts +// Sketch — names illustrative, semantics load-bearing. +interface AiRequestSummary { id: string; status: string; } +interface AiProvider { + readonly id: string; // 'hosted' | 'custom-endpoint' | ... + createRequest(options): Promise<{ id: string }>; + getRequest(id: string): Promise; + addMessage(id: string, message): Promise; + supportsStreaming(): boolean; +} +``` + +- `Generation.js` routes by request-id namespace (`local-*` vs hosted), + exactly as the reference implementation in `packages/unlock-shim/` + demonstrates — the shim's `selectBackend` / `assertNoLocalIdsLeak` + semantics are donated as the starting point. +- Provider registration is explicit and user-configured (endpoint URL + + opt-in flag in Preferences); there is no auto-discovery and no + silent fallback from hosted to custom or back. +- Guardrails that keep this mergeable: + 1. **Credits/quota accounting stays server-side.** Providers report + usage; they never mint entitlements. A custom provider grants no + premium features — it only completes prompts. + 2. **No entitlement surface.** The interface carries no plan, + subscription, or capability objects; paywall logic is untouched. + 3. **Local calls are labeled.** Requests served by a non-hosted + provider carry a distinct id namespace and are excluded from + hosted feedback/training pipelines by construction. + 4. **Enterprise framing:** BYOK as an enterprise/self-hosting feature + (data-residency, key control), not a consumer-unlock story. + +## Reference implementation offered + +- `packages/ai-studio/AI/CustomAIClient.js`: OpenAI-compatible client + (config, tool schema, SSE streaming, retries + fallback routing). +- `packages/unlock-shim/generation-shim.mjs`: routing + fail-closed + leak guard, dependency-free, tested. +- `packages/ai-studio/dev-harness/mock-ai-provider.js`: deterministic + offline provider for CI. + +## Out of scope (will not be proposed) + +Client-side entitlement unlocks, splash/watermark/debugger/preview +gating changes, build-quota changes, or any auto-updater/feed changes. diff --git a/launcher/README.md b/launcher/README.md new file mode 100644 index 0000000..7c59333 --- /dev/null +++ b/launcher/README.md @@ -0,0 +1,55 @@ +# launcher/ — Tier-1 runtime hook (Phase 3, issue #3) + +Runtime guarantee of the Tier-1 unlock when `patches/` alone cannot hold +(e.g. a hunk drifted, or the operator runs a stock build). Defense in +depth, three layers: + +| Layer | File | Mechanism | +|---|---|---| +| Preload seed | `preload-unlock.cjs` | `webPreferences.preload`, runs before the bundle: seeds `window.__HERETEK__`, wraps `fetch` + `XHR` for exactly two GET endpoints | +| Window wrap | `heretek-hook.cjs` | Wraps `electron.BrowserWindow` (loader runs before the main.js:6 destructure), `browser-window-created` + `did-finish-load` fallback for pop-outs/preview | +| Network fallback | `heretek-hook.cjs` | `webRequest.onBeforeRequest` redirects usage GETs to `data:` URLs | + +## Why not `Module._load`? + +The issue draft proposed intercepting `Module._load` for the Usage +module. Production renderers execute a **webpack bundle**, not Node +modules, so that intercept cannot reach `hasValidSubscriptionPlan`. +The app's axios client rides on XHR, and `AuthenticatedUserProvider` +hydrates context from `GET /limits` + `GET /subscription-v2` — spoofing +exactly those two responses unlocks every downstream gate on a stock +build. Only those two GETs are ever intercepted; generation, builds, +auth, and all mutations pass through untouched. + +## Payload contract + +`unlock-payloads.cjs` is the single source of truth, also serialized +into patch 01 by `scripts/generate-tier1-patches.mjs`: + +- `GET {base}/limits` → `{quotas:{}, capabilities, credits:{userBalance:{amount:999999},…}}` + (satisfies `ensureObjectHasProperty(…, 'capabilities')`) +- `GET {base}/subscription-v2` → `{userId:'local-byok-user', planId:'gdevelop_startup',…}` + (satisfies `ensureObjectHasProperty(…, 'userId')`) + +## Install + +`node scripts/apply-patches.mjs --target ` (or `--asar`) applies +patch 06 (loader line at the top of `main.js`) and syncs these three +files next to it. Verify with `node scripts/apply-patches.mjs --check`. + +## Auto-updater pin + +`electron-updater` replaces the whole `app.asar` on update — the loader +line and these files vanish on restart. Mitigations, in order: + +1. `watchUpdater` logs `UPDATER_PIN_MESSAGE` (loud, in all logs) when an + update is staged, and sets `globalThis.__HERETEK__.updatePending`. +2. After every update, re-run + `node scripts/apply-patches.mjs --asar --out ` + **before** launching. The driver refuses a silent no-op. +3. The Phase 1 seam suite (`npm test`) runs weekly against the upstream + baseline — a red suite after an upstream release means the loader + anchor or a hunk moved; regenerate before repacking. + +Do not enable `autoUpdater.autoDownload = true` unattended on hooked +builds: a silent overnight update silently drops Tier-1. diff --git a/launcher/heretek-hook.cjs b/launcher/heretek-hook.cjs new file mode 100644 index 0000000..e9f839c --- /dev/null +++ b/launcher/heretek-hook.cjs @@ -0,0 +1,263 @@ +// Phase 3 main-process hook (issue #3): preload injection for every +// window + webRequest spoof fallback + auto-updater pin. +// +// Installed by a 9-line loader at the TOP of electron-app/app/main.js +// (patches/06-electron-hook.patch) — before line 6 destructures +// `electron.BrowserWindow`, so the wrapped constructor below is the one +// main.js:226 and PreviewWindow.js:52 actually call. Loaded later, the +// wrap would miss those already-captured references (only the +// browser-window-created fallback would remain). +// +// Coverage: +// - First windows: wrapped BrowserWindow merges webPreferences.preload. +// - Portal pop-outs (main.js:361 did-create-window) + preview windows: +// same wrapped constructor; any window that still lacks the +// `__HERETEK__` seed gets the fallback source via did-finish-load + +// executeJavaScript (same mechanism as main.js:261-269). +// - Network fallback: session.defaultSession.webRequest.onBeforeRequest +// redirects GET usage/{limits,subscription-v2} to data: URLs. +// Firebase auth/JWT untouched. +// Testability: `electron` is injectable; tests/launcher/hook.test.cjs +// drives the whole module with fakes (no Electron needed). +'use strict'; + +const path = require('path'); +const { + USAGE_API_BASE, + spoofBodyForUrl, +} = require('./unlock-payloads.cjs'); +const { + getRendererFallbackSource, +} = require('./preload-unlock.cjs'); + +// Absolute path so asar relocation cannot silently drop the hook. +const PRELOAD_PATH = path.join(__dirname, 'preload-unlock.cjs'); + +const SPOOFED_USAGE_URLS = [ + `${USAGE_API_BASE}/limits`, + `${USAGE_API_BASE}/subscription-v2`, +]; + +const UPDATER_PIN_MESSAGE = + '[heretek] update downloaded: the Tier-1 hook (main.js loader line + ' + + 'launcher/*.cjs) is replaced by the updater payload on restart. ' + + 'Re-run node scripts/apply-patches.mjs --asar --out ' + + ' before launching, or Tier-1 gates return. ' + + 'See launcher/README.md ("auto-updater pin").'; + +/** + * Merge the unlock preload into window options. An explicitly set + * preload is never overridden (upstream ships none — frozen by the + * Phase 1 electron seam test). + */ +function withUnlockPreload(options) { + const opts = options || {}; + const webPreferences = Object.assign({}, opts.webPreferences); + if (!webPreferences.preload) webPreferences.preload = PRELOAD_PATH; + return Object.assign({}, opts, { webPreferences }); +} + +function shouldSpoofUrl(url, method) { + if (!url || typeof url !== 'string') return false; + const upperMethod = (method || 'GET').toUpperCase(); + if (upperMethod !== 'GET') return false; + return SPOOFED_USAGE_URLS.some( + prefix => url === prefix || url.indexOf(prefix + '?') === 0 + ); +} + +/** data: URL carrying the unlocked JSON body for a spoofed request. */ +function buildSpoofRedirectUrl(url) { + const body = spoofBodyForUrl(url, 'GET'); + if (body === null) return null; + return ( + 'data:application/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(body)) + ); +} + +/** + * session.webRequest.onBeforeRequest handler. Spoofed endpoints redirect + * to data: URLs; everything else (generation, builds, auth, mutations) + * passes through with an empty action. + */ +function handleBeforeRequest(details, callback) { + const url = details && details.url; + const method = (details && details.method) || 'GET'; + if (url && shouldSpoofUrl(url, method)) { + const redirectURL = buildSpoofRedirectUrl(url); + if (redirectURL) { + callback({ redirectURL }); + return; + } + } + callback({}); +} + +/** + * did-finish-load fallback for windows that missed the preload + * (pop-outs, preview windows, late-created sessions): if the renderer + * has no `__HERETEK__` seed, execute the composed fallback source. + */ +function ensureRendererSeed(webContents, logger) { + if (!webContents || typeof webContents.executeJavaScript !== 'function') + return; + let source; + try { + source = getRendererFallbackSource(); + } catch (error) { + if (logger) logger.error('[heretek] fallback source failed:', error); + return; + } + webContents + .executeJavaScript( + `(function(){ return !!(window.__HERETEK__ && window.__HERETEK__.unlocked); })()` + ) + .then(seeded => { + if (seeded) return null; + return webContents.executeJavaScript(source); + }) + .catch(error => { + if (logger) logger.error('[heretek] seed fallback failed:', error); + }); +} + +function watchWindowCreated(app, logger) { + if (!app || typeof app.on !== 'function') { + if (logger) + logger.error('[heretek] app.on missing — pop-out coverage disabled.'); + return false; + } + app.on('browser-window-created', (event, win) => { + if (!win || !win.webContents) return; + win.webContents.once('did-finish-load', () => { + ensureRendererSeed(win.webContents, logger); + }); + }); + return true; +} + +function watchWebRequest(session, logger) { + if ( + !session || + !session.webRequest || + typeof session.webRequest.onBeforeRequest !== 'function' + ) { + if (logger) + logger.error('[heretek] webRequest unavailable — network fallback off.'); + return false; + } + session.webRequest.onBeforeRequest( + { urls: [`${USAGE_API_BASE}/*`] }, + (details, callback) => handleBeforeRequest(details, callback) + ); + return true; +} + +/** + * Loud warning when the updater stages a payload: on restart the asar + * (loader line + launcher files) is replaced and the hook drops. + * Never blocks the update — just makes the drop impossible to miss. + */ +function watchUpdater(autoUpdater, logger) { + if (!autoUpdater || typeof autoUpdater.on !== 'function') return false; + autoUpdater.on('update-downloaded', () => { + try { + globalThis.__HERETEK__ = Object.assign({}, globalThis.__HERETEK__, { + updatePending: true, + }); + } catch (error) { + // Flag is observability only. + } + if (logger) logger.warn(UPDATER_PIN_MESSAGE); + }); + return true; +} + +function wrapBrowserWindow(electron, logger) { + if (!electron || typeof electron.BrowserWindow !== 'function') { + if (logger) + logger.error('[heretek] electron.BrowserWindow missing — no wrap.'); + return false; + } + const OriginalBrowserWindow = electron.BrowserWindow; + if (OriginalBrowserWindow.__heretekWrapped) return true; + function HeretekBrowserWindow(options) { + return new OriginalBrowserWindow(withUnlockPreload(options)); + } + HeretekBrowserWindow.prototype = OriginalBrowserWindow.prototype; + Object.setPrototypeOf(HeretekBrowserWindow, OriginalBrowserWindow); + Object.defineProperty(HeretekBrowserWindow, '__heretekWrapped', { + value: true, + }); + try { + electron.BrowserWindow = HeretekBrowserWindow; + } catch (error) { + if (logger) logger.error('[heretek] BrowserWindow wrap failed:', error); + return false; + } + return true; +} + +/** + * Main entry, called from the top of main.js (patch 06). `electronModule` + * defaults to require('electron') and is injectable for tests. + */ +function installPreloadHook(electronModule, opts) { + const options = opts || {}; + const logger = options.logger || console; + let electron = electronModule; + if (!electron) { + try { + electron = require('electron'); + } catch (error) { + logger.error('[heretek] require("electron") failed:', error); + return { wrapped: false }; + } + } + const wrapped = wrapBrowserWindow(electron, logger); + const windowsWatched = + electron.app && watchWindowCreated(electron.app, logger); + const requestWatched = + electron.session && + electron.session.defaultSession && + watchWebRequest(electron.session.defaultSession, logger); + let updaterWatched = false; + if (!options.skipUpdaterWatch) { + try { + updaterWatched = watchUpdater( + require('electron-updater').autoUpdater, + logger + ); + } catch (error) { + // Updater optional in tests and minimal shells. + } + } + logger.info( + `[heretek] hook installed (window-wrap: ${wrapped}, ` + + `pop-out fallback: ${!!windowsWatched}, ` + + `webRequest fallback: ${!!requestWatched}, ` + + `updater pin: ${!!updaterWatched}).` + ); + return { + wrapped, + windowsWatched: !!windowsWatched, + requestWatched: !!requestWatched, + updaterWatched: !!updaterWatched, + }; +} + +module.exports = { + PRELOAD_PATH, + SPOOFED_USAGE_URLS, + UPDATER_PIN_MESSAGE, + withUnlockPreload, + shouldSpoofUrl, + buildSpoofRedirectUrl, + handleBeforeRequest, + ensureRendererSeed, + watchWindowCreated, + watchWebRequest, + watchUpdater, + wrapBrowserWindow, + installPreloadHook, +}; diff --git a/launcher/preload-unlock.cjs b/launcher/preload-unlock.cjs new file mode 100644 index 0000000..07126cf --- /dev/null +++ b/launcher/preload-unlock.cjs @@ -0,0 +1,268 @@ +// Phase 3 renderer hook (issue #3): preload seed + fetch/XHR spoof. +// +// Loaded via `webPreferences.preload` (injected by heretek-hook.cjs into +// every BrowserWindow before `electron.BrowserWindow` is destructured). +// Runs before the webpack bundle, with full Node + DOM access +// (`nodeIntegration:true, contextIsolation:false` — frozen by +// tests/seams/electron.seam.test.mjs), sharing the renderer global. +// +// Mechanism (why not Module._load): production renderers execute a +// webpack bundle, not Node modules, so intercepting `Module._load` cannot +// reach `hasValidSubscriptionPlan`. The app's axios client rides on XHR +// and the provider layer (`AuthenticatedUserProvider`) hydrates +// `AuthenticatedUserContext` from GET /limits + GET /subscription-v2 — +// spoofing exactly those two responses unlocks every downstream gate +// (SubscriptionChecker, logo/watermark guards, %16, debugger) on a +// stock unpatched build. Nothing else is intercepted. +// +// Testability: all logic is exported pure functions; the file auto-installs +// only when `window` exists. tests/launcher/preload.test.cjs pins behavior +// and evaluates getRendererFallbackSource() in a vm sandbox (used by +// heretek-hook.cjs for pop-out windows the preload missed). +'use strict'; + +const { + USAGE_API_BASE, + SPOOFED_LIMITS_PATH, + SPOOFED_SUBSCRIPTION_PATH, + UNLOCKED_SUBSCRIPTION, + UNLOCKED_CAPABILITIES, + buildSubscriptionPayload, + buildLimitsPayload, + isSpoofedUsageUrl, + spoofBodyForUrl, +} = require('./unlock-payloads.cjs'); + +function seedIdentity(scope) { + scope.__HERETEK__ = { + unlocked: true, + planId: 'gdevelop_startup', + source: 'preload-unlock', + spoofedRequests: [], + }; +} + +function resolveUrlSafe(url, scope) { + if (!url || typeof url !== 'string') return null; + if (url.indexOf('http://') === 0 || url.indexOf('https://') === 0) + return url; + try { + const base = + scope && scope.location && scope.location.href + ? scope.location.href + : USAGE_API_BASE + '/'; + return new URL(url, base).toString(); + } catch (error) { + return null; + } +} + +function spoofBodyForRequest(rawUrl, method, scope) { + const url = resolveUrlSafe(rawUrl, scope); + if (!url) return null; + return spoofBodyForUrl(url, method); +} + +function recordSpoof(scope, url) { + try { + if (scope && scope.__HERETEK__ && scope.__HERETEK__.spoofedRequests) { + scope.__HERETEK__.spoofedRequests.push(url); + } + } catch (error) { + // Observability only — never break the request path. + } +} + +function createFetchWrapper(originalFetch, scope) { + return function heretekFetch(input, init) { + const rawUrl = + typeof input === 'string' + ? input + : input && input.url + ? input.url + : null; + const method = + (init && init.method) || (input && input.method) || 'GET'; + const body = spoofBodyForRequest(rawUrl, method, scope); + if (body !== null) { + recordSpoof(scope, resolveUrlSafe(rawUrl, scope)); + return Promise.resolve( + new scope.Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + } + return originalFetch.apply(this, arguments); + }; +} + +function setXhrResponse(xhr, body) { + const text = JSON.stringify(body); + try { + xhr.readyState = 4; + xhr.status = 200; + xhr.responseText = text; + xhr.response = text; + } catch (error) { + // Real XHR attributes are getter-only on the prototype: shadow them + // per-instance instead. + Object.defineProperty(xhr, 'readyState', { + value: 4, + configurable: true, + }); + Object.defineProperty(xhr, 'status', { value: 200, configurable: true }); + Object.defineProperty(xhr, 'responseText', { + value: text, + configurable: true, + }); + Object.defineProperty(xhr, 'response', { + value: text, + configurable: true, + }); + } +} + +function fireXhrLoad(xhr) { + if (typeof xhr.onreadystatechange === 'function') { + try { + xhr.onreadystatechange(); + } catch (error) { + // Listener errors must not break the spoof. + } + } + if (typeof xhr.onload === 'function') { + try { + xhr.onload(); + } catch (error) { + // Listener errors must not break the spoof. + } + } +} + +function patchXhr(XHRClass, scope) { + const originalOpen = XHRClass.prototype.open; + const originalSend = XHRClass.prototype.send; + XHRClass.prototype.open = function heretekOpen(method, url) { + this.__heretekMethod = method; + this.__heretekUrl = url; + return originalOpen.apply(this, arguments); + }; + XHRClass.prototype.send = function heretekSend() { + const args = arguments; + const self = this; + const body = spoofBodyForRequest( + this.__heretekUrl, + this.__heretekMethod, + scope + ); + if (body !== null) { + recordSpoof(scope, resolveUrlSafe(this.__heretekUrl, scope)); + const deliver = function deliverSpoofedXhrResponse() { + setXhrResponse(self, body); + fireXhrLoad(self); + }; + const scheduler = + scope && typeof scope.setTimeout === 'function' + ? scope.setTimeout.bind(scope) + : typeof globalThis.setTimeout === 'function' + ? globalThis.setTimeout.bind(globalThis) + : null; + if (scheduler) scheduler(deliver, 0); + else deliver(); + return; + } + return originalSend.apply(this, args); + }; + return XHRClass; +} + +function installRendererHook(scope) { + seedIdentity(scope); + if (scope.fetch) { + try { + scope.fetch = createFetchWrapper(scope.fetch.bind(scope), scope); + } catch (error) { + // Leave the original fetch alone rather than breaking networking. + } + } + if (scope.XMLHttpRequest) { + try { + patchXhr(scope.XMLHttpRequest, scope); + } catch (error) { + // Leave the original XHR alone rather than breaking networking. + } + } + return scope.__HERETEK__; +} + +// Names embedded into pop-out/preview windows that missed the preload +// (heretek-hook.cjs, did-finish-load fallback). Every name below must be +// self-contained apart from the embedded consts — the vm-sandbox test in +// tests/launcher/preload.test.cjs fails if a closure leaks. +const FALLBACK_EMBEDDED_CONSTS = [ + 'USAGE_API_BASE', + 'SPOOFED_LIMITS_PATH', + 'SPOOFED_SUBSCRIPTION_PATH', + 'UNLOCKED_SUBSCRIPTION', + 'UNLOCKED_CAPABILITIES', +]; +const FALLBACK_EMBEDDED_FUNCTIONS = [ + 'isSpoofedUsageUrl', + 'buildSubscriptionPayload', + 'buildLimitsPayload', + 'spoofBodyForUrl', + 'seedIdentity', + 'resolveUrlSafe', + 'spoofBodyForRequest', + 'recordSpoof', + 'createFetchWrapper', + 'setXhrResponse', + 'fireXhrLoad', + 'patchXhr', + 'installRendererHook', +]; + +function getRendererFallbackSource() { + const payloads = require('./unlock-payloads.cjs'); + const preload = module.exports; + const namespace = Object.assign({}, payloads, preload); + const consts = FALLBACK_EMBEDDED_CONSTS.map( + name => `var ${name} = ${JSON.stringify(payloads[name])};` + ).join('\n'); + const fns = FALLBACK_EMBEDDED_FUNCTIONS.map(name => { + const fn = namespace[name]; + if (typeof fn !== 'function') + throw new Error(`[heretek] cannot embed missing function: ${name}`); + return fn.toString(); + }).join('\n'); + return ( + '(function(){\n' + + consts + + '\n' + + fns + + '\ninstallRendererHook(window);\n' + + '})();' + ); +} + +// Auto-install in a real renderer only. In Node (tests, main process) +// `window` is undefined and this module is side-effect free. +if (typeof window !== 'undefined' && window && window.document) { + installRendererHook(window); +} + +module.exports = { + seedIdentity, + resolveUrlSafe, + spoofBodyForRequest, + recordSpoof, + createFetchWrapper, + setXhrResponse, + fireXhrLoad, + patchXhr, + installRendererHook, + getRendererFallbackSource, + FALLBACK_EMBEDDED_CONSTS, + FALLBACK_EMBEDDED_FUNCTIONS, +}; diff --git a/launcher/unlock-payloads.cjs b/launcher/unlock-payloads.cjs new file mode 100644 index 0000000..cc0b1a2 --- /dev/null +++ b/launcher/unlock-payloads.cjs @@ -0,0 +1,141 @@ +// Single source of truth for Tier-1 unlock payloads (Phase 3). +// +// Consumed by: +// - scripts/generate-tier1-patches.mjs (serializes these objects into +// patch 01, so patches/ and the runtime hook can never disagree), +// - launcher/preload-unlock.cjs (renderer fetch/XHR spoof bodies + +// fallback-source composition), +// - launcher/heretek-hook.cjs (main-process webRequest spoof bodies). +// +// Shapes satisfy the upstream validators frozen in Phase 1: +// GET /limits -> ensureObjectHasProperty(..., 'capabilities') +// GET /subscription-v2 -> ensureObjectHasProperty(..., 'userId') +// Full URLs: https://api.gdevelop.io/usage/{limits,subscription-v2}. +// Firebase auth/JWT untouched — logged-out walls elsewhere remain. +'use strict'; + +const USAGE_API_BASE = 'https://api.gdevelop.io/usage'; +const SPOOFED_LIMITS_PATH = '/limits'; +const SPOOFED_SUBSCRIPTION_PATH = '/subscription-v2'; + +// Mirrors UNLOCKED_HERETEK_SUBSCRIPTION in patch 01 (and the fork). +const UNLOCKED_SUBSCRIPTION = { + userId: 'local-byok-user', + planId: 'gdevelop_startup', + pricingSystemId: 'HERETEK_UNLOCKED', + createdAt: 0, + updatedAt: 0, +}; + +// Mirrors UNLOCKED_HERETEK_CAPABILITIES in patch 01 (and the fork). +// Satisfies the upstream `Capabilities` Flow type. +const UNLOCKED_CAPABILITIES = { + analytics: { + sessions: true, + players: true, + retention: true, + sessionsTimeStats: true, + platforms: true, + }, + cloudProjects: { + maximumCount: 999, + canMaximumCountBeIncreased: true, + maximumGuestCollaboratorsPerProject: 99, + projectRestorationTimeWindowInSeconds: -1, + deletedProjectRetentionInDays: 365, + }, + leaderboards: { + maximumCountPerGame: 999, + canMaximumCountPerGameBeIncreased: true, + themeCustomizationCapabilities: 'FULL', + canUseCustomCss: true, + canDisableLoginInLeaderboard: true, + }, + multiplayer: { + lobbiesCount: 99, + maxPlayersPerLobby: 64, + themeCustomizationCapabilities: 'FULL', + }, + versionHistory: { + enabled: true, + retentionDays: 365, + }, + ai: { + availablePresets: [], + versionHistory: { + enabled: true, + retentionDays: 365, + }, + }, +}; + +// NOTE: keep these builders dependency-free and self-contained (no +// module-scope references besides the UNLOCKED_* consts, which the +// fallback-source composer embeds by name). preload-unlock.cjs stringifies +// them into renderer windows, so closures over other locals WILL break +// the fallback path — tests/launcher/preload.test.cjs evaluates the +// composed source in a vm sandbox to pin this. +function buildSubscriptionPayload( + subscription /*: void | {||} */ = UNLOCKED_SUBSCRIPTION +) { + return subscription; +} + +function buildLimitsPayload( + capabilities /*: void | {||} */ = UNLOCKED_CAPABILITIES +) { + return { + quotas: {}, + capabilities: capabilities, + credits: { + userBalance: { amount: 999999 }, + prices: {}, + purchasableQuantities: [], + }, + message: undefined, + }; +} + +/** + * True only for the two GET endpoints the Tier-1 runtime hook spoofs. + * Everything else (generation, builds, auth, POST/PUT/DELETE) passes + * through untouched — the hook never mints credits or touches hosted + * mutations. + */ +function isSpoofedUsageUrl(url, method) { + if (!url || typeof url !== 'string') return false; + const upperMethod = (method || 'GET').toUpperCase(); + if (upperMethod !== 'GET') return false; + if (url.indexOf(USAGE_API_BASE) !== 0) return false; + const pathAndQuery = url.slice(USAGE_API_BASE.length); + return ( + pathAndQuery === SPOOFED_LIMITS_PATH || + pathAndQuery.indexOf(SPOOFED_LIMITS_PATH + '?') === 0 || + pathAndQuery === SPOOFED_SUBSCRIPTION_PATH || + pathAndQuery.indexOf(SPOOFED_SUBSCRIPTION_PATH + '?') === 0 + ); +} + +function spoofBodyForUrl(url, method) { + if (!isSpoofedUsageUrl(url, method)) return null; + const pathAndQuery = url.slice(USAGE_API_BASE.length); + if ( + pathAndQuery === SPOOFED_LIMITS_PATH || + pathAndQuery.indexOf(SPOOFED_LIMITS_PATH + '?') === 0 + ) { + return buildLimitsPayload(); + } + return buildSubscriptionPayload(); +} + +module.exports = { + USAGE_API_BASE, + SPOOFED_LIMITS_PATH, + SPOOFED_SUBSCRIPTION_PATH, + UNLOCKED_SUBSCRIPTION, + UNLOCKED_CAPABILITIES, + buildSubscriptionPayload, + buildLimitsPayload, + isSpoofedUsageUrl, + spoofBodyForUrl, +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..50a0857 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "gdevelop-unlocked", + "version": "0.1.0", + "private": true, + "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", + "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", + "patches:check": "node scripts/apply-patches.mjs --check", + "patches:generate": "node scripts/generate-tier1-patches.mjs --upstream review/GDevelop" + }, + "engines": { + "node": ">=22" + } +} diff --git a/packages/ai-studio/AI/CustomAIClient.js b/packages/ai-studio/AI/CustomAIClient.js new file mode 100644 index 0000000..d1e41b3 --- /dev/null +++ b/packages/ai-studio/AI/CustomAIClient.js @@ -0,0 +1,5870 @@ +// @flow + +// nosonar: javascript:S2245 — Math.random() is used +// here only to generate non-cryptographic unique IDs +// (callId, reqId, messageId, etc.) and for UI variant +// selection. These values are not used as security tokens, +// session keys, or anything that requires unpredictability. + +import axios from 'axios'; +import { + getStudioRole, + getToolsForRole, + isStudioRoleId, + resolveStudioRoleId, + READ_ONLY_TOOL_NAMES, +} from '../AiGeneration/Studio/Roles'; +import { + type AiRequest, + type AiRequestMessage, + type AiRequestFunctionCallOutput, + type AiRequestUserMessage, + type AiRequestSuggestions, + type AiGeneratedEvent, + type CreateAiGeneratedEventResult, + type AssetSearch, + type ResourceSearch, + type GenerationStatus, + type AiSettings, +} from '../Utils/GDevelopServices/Generation'; + +export const LOCAL_BYOK_USER_ID = 'local-byok-user'; + +export type CustomAIConfig = {| + enabled: boolean, + baseUrl: string, + apiKey: string, + model: string, + /** + * Optional secondary model used when the primary model exhausts its provider + * retries on a transient 5xx (fallback routing). Empty/absent disables it. + */ + fallbackModel?: string, + temperature: number, + maxTokens?: number, + customHeaders?: { [string]: string }, + /** Per-request timeout in milliseconds. Defaults to 120000 (slow local + * models on large contexts need more than axios' generic timeout message + * suggests). */ + timeoutMs?: number, + /** Stream responses via SSE when the endpoint supports it (reduces + * time-to-first-token in agent loops). Falls back to a non-streaming + * request automatically if the server rejects streaming. */ + streaming?: boolean, +|}; + +/** + * Upper bound for a user-configured request timeout. + * + * `setTimeout` — and axios, which wraps it — stores its delay in a 32-bit + * signed integer. A delay above 2^31-1 ms overflows and is clamped to 1 ms by + * the runtime, so an oversized timeout in the AI preferences aborted every + * request almost immediately, with a misleading "timed out after N ms" + * message. Clamping to the platform maximum keeps the configured delay honest. + */ +export const MAX_TIMEOUT_MS = 2147483647; + +export const DEFAULT_CUSTOM_AI_CONFIG: CustomAIConfig = { + enabled: false, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'qwen2.5-coder', + temperature: 0.7, +}; + +const LOCAL_STORAGE_CONFIG_KEY = 'gd-custom-ai-config'; +const LOCAL_STORAGE_REQUESTS_KEY = 'gd-custom-ai-requests'; +const LOCAL_STORAGE_MODEL_OVERRIDES_KEY = 'gd-custom-ai-model-overrides'; +const LOCAL_STORAGE_TOKEN_TOTALS_KEY = 'gd-custom-ai-token-totals'; +const LOCAL_STORAGE_SUBAGENT_REQUESTS_KEY = 'gd-custom-ai-subagent-requests'; +const MAX_LOCAL_SAVED_REQUESTS = 20; +/** + * Cap on persisted sub-agent requests. They cannot share the parent recency + * budget (20 chats would evict real conversations after one multi-agent run), + * so they get their own store. A run spawns a handful; 40 covers several. + */ +const MAX_LOCAL_SAVED_SUBAGENT_REQUESTS = 40; +/** + * Cap on persisted per-request token totals. The map is keyed by request id + * (sub-agent children included, so it outgrows the 20 saved chats), and must + * not grow without bound across sessions. + */ +const MAX_LOCAL_SAVED_TOKEN_TOTALS = 500; + +/** + * In-memory configuration cache. + */ +let cachedConfig: ?CustomAIConfig = null; + +/** + * Whether a user-configured header is safe to attach to an outgoing request. + * Rejects CR/LF/NUL in the name or value (header injection) and hop-by-hop + * headers that would break or redirect the JSON request if left to overrides. + * Content-Type is also rejected here: custom headers are applied before the + * Authorization/API key, but Content-Type must stay application/json. + */ +const isSafeRequestHeader = (name: string, value: string): boolean => { + if (typeof name !== 'string' || typeof value !== 'string') return false; + // RFC 7230 token (no colon/spaces/controls) — also blocks CR/LF injection. + if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name)) return false; + // No CR/LF/NUL in values (header splitting). + if (/[\r\n\0]/.test(value)) return false; + const lower = name.toLowerCase(); + if ( + lower === 'content-type' || + lower === 'content-length' || + lower === 'host' || + lower === 'connection' || + lower === 'transfer-encoding' || + lower === 'keep-alive' || + lower === 'upgrade' || + lower === 'te' || + lower === 'trailer' || + lower === 'proxy-authorization' || + lower === 'proxy-connection' + ) { + return false; + } + return true; +}; + +/** + * Coerce arbitrary JSON (localStorage) or a `$Shape` update into a + * well-typed CustomAIConfig. Fail-closed on bad types so a corrupt entry + * can never crash a model call, poison the live cache, or leak through the + * header-building path. + */ +const sanitizeCustomAIConfig = (input: mixed): CustomAIConfig => { + // JSON null / scalars / arrays are not configs — fall back wholesale so + // baseUrl/model do not collapse to '' (which would fight the default host). + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return { ...DEFAULT_CUSTOM_AI_CONFIG }; + } + const parsed: { [string]: any } = (input: any); + return { + ...DEFAULT_CUSTOM_AI_CONFIG, + enabled: parsed.enabled === true, + baseUrl: typeof parsed.baseUrl === 'string' ? parsed.baseUrl : '', + apiKey: typeof parsed.apiKey === 'string' ? parsed.apiKey : '', + model: typeof parsed.model === 'string' ? parsed.model : '', + fallbackModel: + typeof parsed.fallbackModel === 'string' + ? parsed.fallbackModel.trim() + : undefined, + temperature: + typeof parsed.temperature === 'number' && + Number.isFinite(parsed.temperature) + ? Math.max(0, Math.min(1, parsed.temperature)) + : DEFAULT_CUSTOM_AI_CONFIG.temperature, + timeoutMs: + typeof parsed.timeoutMs === 'number' && + Number.isFinite(parsed.timeoutMs) && + parsed.timeoutMs > 0 + ? Math.min(parsed.timeoutMs, MAX_TIMEOUT_MS) + : undefined, + streaming: parsed.streaming === true, + maxTokens: + typeof parsed.maxTokens === 'number' && + Number.isFinite(parsed.maxTokens) && + parsed.maxTokens > 0 + ? parsed.maxTokens + : undefined, + customHeaders: + parsed.customHeaders && + typeof parsed.customHeaders === 'object' && + !Array.isArray(parsed.customHeaders) + ? Object.fromEntries( + // $FlowExpectedError[incompatible-type] Object.entries widens the value type. + (Object.entries(parsed.customHeaders): Array< + [string, string] + >).filter( + ([name, value]) => + typeof value === 'string' && isSafeRequestHeader(name, value) + ) + ) + : undefined, + }; +}; + +/** + * Load custom AI config from local storage or default. + */ +export const getCustomEndpointConfig = (): CustomAIConfig => { + if (cachedConfig) { + return cachedConfig; + } + + try { + if (typeof localStorage !== 'undefined') { + const persisted = localStorage.getItem(LOCAL_STORAGE_CONFIG_KEY); + if (persisted) { + const parsed = JSON.parse(persisted); + cachedConfig = sanitizeCustomAIConfig(parsed); + return cachedConfig; + } + } + } catch (err) { + console.warn('Error reading custom AI config from localStorage:', err); + } + + cachedConfig = { ...DEFAULT_CUSTOM_AI_CONFIG }; + return cachedConfig; +}; + +/** + * Save custom AI config to local storage and update in-memory cache. + * Sanitizes updates before caching so a mistyped field cannot poison the + * live session (getCustomEndpointConfig short-circuits on the cache). + * Excludes apiKey from cleartext localStorage while retaining it in memory. + */ +export const setCustomEndpointConfig = ( + updates: $Shape +): CustomAIConfig => { + const current = getCustomEndpointConfig(); + const nextConfig: CustomAIConfig = sanitizeCustomAIConfig({ + ...current, + ...updates, + }); + cachedConfig = nextConfig; + + try { + if (typeof localStorage !== 'undefined') { + const { apiKey, ...persistableConfig } = nextConfig; + localStorage.setItem( + LOCAL_STORAGE_CONFIG_KEY, + JSON.stringify(persistableConfig) + ); + } + } catch (err) { + console.warn('Error saving custom AI config to localStorage:', err); + } + + return nextConfig; +}; + +/** + * Check if the custom AI endpoint is enabled. + */ +export const isCustomEndpointEnabled = (): boolean => { + const config = getCustomEndpointConfig(); + return !!config.enabled; +}; + +/** + * Normalize base URL ensuring protocol and removing trailing slashes. + * Preserves the configured path exactly without automatically appending /v1. + */ +export const normalizeBaseUrl = (baseUrl: string): string => { + // Non-strings are treated as absent rather than coerced: this is the single + // entry point for every request path, and a caller-supplied or + // localStorage-restored value is not guaranteed to be a string (the + // preferences hydrate each stored key without type-checking it). Calling + // .trim() on a number threw a TypeError before any request was made. + let url = (typeof baseUrl === 'string' ? baseUrl : '').trim(); + if (!url) { + return 'http://localhost:11434/v1'; + } + // Remove trailing slashes + url = url.replace(/\/+$/, ''); + + if (!/^https?:\/\//i.test(url)) { + // A malformed scheme must not be "repaired" by prefixing https:// — that + // turns a one-character typo (`htpp://localhost:11434`) into the host + // `htpp` and fails later with a confusing DNS error. Any `scheme://` + // prefix that is not http(s) is reported as-is so the message names the + // real problem. + const anyScheme = url.match(/^([a-z][a-z0-9+.-]*):\/\//i); + if (anyScheme) return url; + + // A bare `scheme:path` with no slashes (a mangled paste) is equally wrong — + // but `host:port` is also `word:digits`, so only treat it as a scheme when + // what follows the colon is NOT a port. + const bareScheme = url.match(/^([a-z][a-z0-9+.-]*):(?!\d+(?:\/|$))/i); + if (bareScheme) return url; + + // Genuinely schemeless: default to http:// for loopback and https:// + // otherwise. + if (/^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?(\/.*)?$/i.test(url)) { + url = `http://${url}`; + } else { + url = `https://${url}`; + } + } + return url; +}; + +/** + * Build the full endpoint URL for a given path. + */ +export const getEndpointUrl = ( + baseUrl: string, + endpointPath: string +): string => { + const normalized = normalizeBaseUrl(baseUrl); + const cleanPath = endpointPath.startsWith('/') + ? endpointPath + : `/${endpointPath}`; + + return `${normalized}${cleanPath}`; +}; + +/** + * In-memory cache for local AI requests. + */ +const localAiRequestsCache: { [id: string]: AiRequest } = {}; + +/** + * In-flight turn cancellation: one AbortController per running model turn, + * keyed by request id (sub-agents remember their parent so a parent Stop can + * cancel them). `customSuspendAiRequest` aborts them; each turn releases its + * entry when the model call settles. + */ +const localAiRequestAbortControllers: { + [id: string]: {| + controller: AbortController, + parentAiRequestId: string | null, + |}, +} = {}; + +/** + * Local create is not visible to the UI until the first turn settles (the + * request id only lands in the cache then). Track in-flight create ids so Stop + * can cancel a hung Ollama/VRAM first turn before any AiRequest exists. + */ +const pendingCreateAiRequestIds: { [id: string]: boolean } = {}; + +/** Set when Stop is pressed before create has registered its abort controller. */ +let createAbortRequested = false; + +export const customHasPendingCreateAiRequest = (): boolean => + Object.keys(pendingCreateAiRequestIds).length > 0 || createAbortRequested; + +export const customAbortPendingCreateAiRequests = (): void => { + const ids = Object.keys(pendingCreateAiRequestIds); + if (ids.length === 0) { + // Stop arrived before create registered: remember so create cancels on entry. + createAbortRequested = true; + return; + } + for (let i = 0; i < ids.length; i++) { + abortTurnsForRequest(ids[i]); + } +}; + +/** + * Number of history messages trimmed to fit the context budget, per request: + * the chat UI surfaces this so users understand why the agent may have + * "forgotten" old exchanges. + */ +const localAiRequestTrimCounts: { [id: string]: number } = {}; + +export const customGetAiRequestContextTrimCount = ( + aiRequestId: string +): number => localAiRequestTrimCounts[aiRequestId] || 0; + +/** + * Whether the system project-structure prompt was compacted for this request. + * History trims only drop conversation messages; system compaction silently + * shortens the structure the model sees — surface that distinctly in the UI. + */ +const localAiRequestSystemCompacted: { [id: string]: boolean } = {}; + +export const customGetAiRequestSystemCompacted = ( + aiRequestId: string +): boolean => !!localAiRequestSystemCompacted[aiRequestId]; + +const noteSystemCompactedIfChanged = ( + aiRequestId: string, + beforeMessages: Array, + afterMessages: Array +): void => { + let beforeContent = ''; + let afterContent = ''; + for (let i = 0; i < beforeMessages.length; i++) { + const message = beforeMessages[i]; + if (message && message.role === 'system') { + beforeContent = message.content; + break; + } + } + for (let i = 0; i < afterMessages.length; i++) { + const message = afterMessages[i]; + if (message && message.role === 'system') { + afterContent = message.content; + break; + } + } + if ( + typeof beforeContent === 'string' && + typeof afterContent === 'string' && + beforeContent !== afterContent + ) { + localAiRequestSystemCompacted[aiRequestId] = true; + } +}; + +/** + * Cumulative estimated tokens (prompt + response) consumed per request, for + * the local/BYOK cost meter in the chat UI. Estimate only (chars/4) — never + * a billing figure. + */ +const localAiRequestTokenTotals: { [id: string]: number } = {}; + +/** + * The prompt size of a request's most recent turn: how much of the model's + * context window the conversation occupies right now. + * + * Distinct from `localAiRequestTokenTotals`, which is a cumulative cost meter + * that grows every turn. Occupancy must be the last prompt's size, not the sum + * of every turn ever billed — dividing the cumulative total by the per-request + * budget would inflate forever and say nothing about the window. + */ +const localAiRequestContextTokens: { [id: string]: number } = {}; + +export const customGetAiRequestContextTokens = (aiRequestId: string): number => + localAiRequestContextTokens[aiRequestId] || 0; + +/** + * Last provider telemetry seen for a request (per-turn latency, tokens, model, + * cache), from routing/proxy headers (`x-omniroute-*`) when the endpoint sends + * them. Makes the per-turn observability the console also prints inspectable + * from React state (the auditing data layer for a per-agent dashboard). Sub + * -agent turns attribute to their parent chat, like the other registries. + */ +const localAiRequestProviderTelemetry: { [id: string]: Object } = {}; + +export const customGetAiRequestProviderTelemetry = ( + aiRequestId: string +): ?Object => localAiRequestProviderTelemetry[aiRequestId] || null; + +/** + * Tokens a completion billed for: its answer plus any chain of thought. + * + * A reasoning model can spend most of its output on `reasoning_content` + * (cycle 80 made the stream carry it), so counting only `content` would under- + * report the local cost meter for exactly the models whose usage users watch. + * A non-streamed reply carries the same field, so both paths total the same. + */ +const completionOutputTokens = (response: Object): number => + estimateTokens(response && response.content) + + estimateTokens(response && response.reasoning_content) + + estimateTokens(response && response.reasoning); + +const addTokenUsage = ( + aiRequestId: string, + promptTokens: number, + responseTokens: number +): void => { + localAiRequestTokenTotals[aiRequestId] = + (localAiRequestTokenTotals[aiRequestId] || 0) + + promptTokens + + responseTokens; + saveLocalAiRequestTokenTotals(); +}; + +export const customGetAiRequestTokenTotal = (aiRequestId: string): number => + localAiRequestTokenTotals[aiRequestId] || 0; + +/** + * Per-request token totals, persisted. + * + * The token meters used to be in-memory only, so a reloaded chat lost every + * figure - sub-agent totals included (the per-agent audit dashboard sums the + * children's meters). The numbers are tiny, so they are stored on their own + * rather than folded into every saved request. Bounded by + * `MAX_LOCAL_SAVED_TOKEN_TOTALS`: the oldest entries beyond the cap are dropped + * (object key order is insertion order), so the map cannot grow forever. + */ +const saveLocalAiRequestTokenTotals = (): void => { + if (typeof localStorage === 'undefined') return; + try { + const keys = Object.keys(localAiRequestTokenTotals); + const excess = keys.length - MAX_LOCAL_SAVED_TOKEN_TOTALS; + for (let i = 0; i < excess; i++) { + delete localAiRequestTokenTotals[keys[i]]; + } + localStorage.setItem( + LOCAL_STORAGE_TOKEN_TOTALS_KEY, + JSON.stringify(localAiRequestTokenTotals) + ); + } catch (err) { + console.warn('Error saving local AI token totals:', err); + } +}; + +const loadLocalAiRequestTokenTotals = (): void => { + if (typeof localStorage === 'undefined') return; + try { + const persisted = localStorage.getItem(LOCAL_STORAGE_TOKEN_TOTALS_KEY); + if (!persisted) return; + const parsed = JSON.parse(persisted); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return; + for (const key of Object.keys(parsed)) { + const value = parsed[key]; + // Only a non-negative finite number: a corrupt entry must not become a + // token count (which is rendered and summed). + if (typeof value === 'number' && Number.isFinite(value) && value >= 0) { + localAiRequestTokenTotals[key] = value; + } + } + } catch (err) { + console.warn('Error reading local AI token totals:', err); + } +}; + +/** Exposed so a test can simulate a fresh session's load. */ +export const loadLocalAiRequestTokenTotalsForTesting = (): void => + loadLocalAiRequestTokenTotals(); + +/** Exposed so a test can seed a token total without a model call. */ +export const recordLocalAiRequestTokenUsageForTesting = ( + aiRequestId: string, + promptTokens: number, + responseTokens: number +): void => addTokenUsage(aiRequestId, promptTokens, responseTokens); + +/** + * Per-request model override: an empty string clears the override and falls + * back to the globally configured model. Survives via the request-scoped + * registries until the client state is reset. + */ +/** + * Latest streamed partial content per in-flight request, for the progressive + * "typing" display in the chat UI. Cleared when the turn settles. + */ +const localAiRequestPartialContent: { [id: string]: string } = {}; + +export const customGetAiRequestPartialContent = (aiRequestId: string): string => + localAiRequestPartialContent[aiRequestId] || ''; + +const localAiRequestModelOverrides: { [id: string]: string } = {}; + +/** + * Per-chat model choice, persisted. + * + * The override lives outside the AiRequest record, so persisting the request + * alone lost it: reopening the editor silently reverted a chat to the global + * model, which for a BYOK setup often means a different (sometimes unavailable) + * model than the one the user picked for that conversation. The values are + * short strings, so they are stored separately rather than inflating every + * saved request. + */ +const saveLocalAiRequestModelOverrides = (): void => { + if (typeof localStorage === 'undefined') return; + try { + localStorage.setItem( + LOCAL_STORAGE_MODEL_OVERRIDES_KEY, + JSON.stringify(localAiRequestModelOverrides) + ); + } catch (err) { + console.warn('Error saving local AI model overrides:', err); + } +}; + +const loadLocalAiRequestModelOverrides = (): void => { + if (typeof localStorage === 'undefined') return; + try { + const persisted = localStorage.getItem(LOCAL_STORAGE_MODEL_OVERRIDES_KEY); + if (!persisted) return; + const parsed = JSON.parse(persisted); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return; + for (const key of Object.keys(parsed)) { + const value = parsed[key]; + // Only non-empty strings: a corrupt entry must not become a model name. + if (typeof value === 'string' && value.trim()) { + localAiRequestModelOverrides[key] = value.trim(); + } + } + } catch (err) { + console.warn('Error reading local AI model overrides:', err); + } +}; + +/** Exposed so a test can simulate a fresh session's load. */ +export const loadLocalAiRequestModelOverridesForTesting = (): void => + loadLocalAiRequestModelOverrides(); + +export const customSetAiRequestModelOverride = ( + aiRequestId: string, + model: string +): void => { + const trimmedModel = (model || '').trim(); + if (trimmedModel) { + localAiRequestModelOverrides[aiRequestId] = trimmedModel; + } else { + delete localAiRequestModelOverrides[aiRequestId]; + } + saveLocalAiRequestModelOverrides(); +}; + +export const customGetAiRequestModelOverride = (aiRequestId: string): string => + localAiRequestModelOverrides[aiRequestId] || ''; + +/** + * Effective endpoint config for one request: the global config with the + * per-request model override applied (used for the request and its + * sub-agents, so the whole chat stays on one model). + */ +export const getEffectiveConfigForRequest = ( + aiRequestId: string +): CustomAIConfig => { + const config = getCustomEndpointConfig(); + const modelOverride = localAiRequestModelOverrides[aiRequestId]; + return modelOverride ? { ...config, model: modelOverride } : config; +}; + +const registerTurnAbortController = ( + aiRequestId: string, + parentAiRequestId?: string | null +): AbortController => { + const controller = new AbortController(); + localAiRequestAbortControllers[aiRequestId] = { + controller, + parentAiRequestId: parentAiRequestId || null, + }; + return controller; +}; + +const releaseTurnAbortController = (aiRequestId: string): void => { + delete localAiRequestAbortControllers[aiRequestId]; +}; + +const abortTurnsForRequest = (aiRequestId: string): void => { + const entry = localAiRequestAbortControllers[aiRequestId]; + if (entry) entry.controller.abort(); + for (const key of Object.keys(localAiRequestAbortControllers)) { + const other = localAiRequestAbortControllers[key]; + if (other && other.parentAiRequestId === aiRequestId) { + other.controller.abort(); + } + } +}; + +/** + * One in-flight model turn per request id. + * + * A turn reads `localAiRequestsCache[id]`, awaits a full model call, then writes + * the whole updated request back. Once the studio spawns a sub-agent, a parent + * and its child are both taking turns, so that read-to-write window is a real + * lost-update race: without this lock the later write discards the earlier one's + * appended message. + * + * Different request ids never block each other. + */ +const localAiTurnTails: { [aiRequestId: string]: Promise } = {}; + +/** + * Run `turn` with the exclusive turn lock for `aiRequestId`, releasing it when + * the turn settles. The lock is a promise tail, so a queued turn only starts + * after the previous one has released it, and different request ids never block + * each other. + */ +export const withLocalAiTurnLock = async ( + aiRequestId: string, + turn: () => Promise +): Promise => { + const tail = localAiTurnTails[aiRequestId] || Promise.resolve(); + let release: () => void; + const held = new Promise(resolve => { + release = resolve; + }); + const newTail = tail.then(() => held); + localAiTurnTails[aiRequestId] = newTail; + + await tail; + try { + return await turn(); + } finally { + release(); + // Drop the entry once nothing is queued behind this turn. + if (localAiTurnTails[aiRequestId] === newTail) { + delete localAiTurnTails[aiRequestId]; + } + } +}; + +/** + * Rough token estimate for prompt-budget purposes. Deliberately + * dependency-free and local-only — good enough to decide whether the context + * window is in danger, not to bill anyone exactly. + * + * ASCII text costs about four characters per token, but non-Latin scripts do + * not: CJK is roughly one token per character, and a project whose object and + * scene names are Chinese (GDevelop ships zh_CN/ja_JP/ko_KR) is mostly + * non-ASCII. Counting those at the ASCII rate under-reported such prompts by + * ~4x, so the "budget" permitted the over-window request it exists to prevent. + * Non-ASCII code units are therefore charged one token each. + */ +const NON_LATIN_TOKEN_RATIO = 1; + +const countNonAsciiChars = (text: string): number => { + let count = 0; + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) > 0x7f) count += 1; + } + return count; +}; + +export const estimateTokens = (text: ?string): number => { + if (!text || typeof text !== 'string') return 0; + const nonAscii = countNonAsciiChars(text); + const ascii = text.length - nonAscii; + return Math.ceil(ascii / 4) + Math.ceil(nonAscii * NON_LATIN_TOKEN_RATIO); +}; + +/** + * Longest prefix of `text` that stays within `maxTokens`. + * + * The inverse of estimateTokens, and it must stay one: converting a token + * budget to a character count with a flat `* 4` assumes every character costs + * a quarter token, so after estimateTokens learned that CJK costs a full + * token the two disagreed and a "capped" CJK prompt came out 4x over its + * budget. Budget in quarter-tokens so the two use the same exchange rate. + */ +const sliceToTokenBudget = (text: string, maxTokens: number): string => { + const maxQuarters = Math.max(0, maxTokens) * 4; + let quarters = 0; + let end = text.length; + for (let i = 0; i < text.length; i++) { + const cost = text.charCodeAt(i) > 0x7f ? 4 : 1; + if (quarters + cost > maxQuarters) { + end = i; + break; + } + quarters += cost; + } + // Never cut between the halves of a surrogate pair. An astral character + // (emoji, rare CJK ideograph — both common in GDevelop scene/object names, + // and the JSON project structure embeds them) is two UTF-16 code units; + // slicing between them leaves a lone high surrogate that encodes to U+FFFD, + // so the truncated prompt reached the model with a replacement character in + // place of the real name. + if ( + end > 0 && + end < text.length && + text.charCodeAt(end - 1) >= 0xd800 && + text.charCodeAt(end - 1) <= 0xdbff + ) { + end -= 1; + } + return end === text.length ? text : text.slice(0, end); +}; + +const DEFAULT_CONTEXT_WINDOW = 128000; +// Conservative context windows per model family (local models are small). +const FAMILY_CONTEXT_WINDOWS = { + llama: 8192, + mistral: 32768, + qwen: 32768, + gpt: 128000, + claude: 128000, + deepseek: 128000, +}; + +/** + * Context window for a model, by family. + */ +const getContextWindow = (config?: ?CustomAIConfig): number => { + const model = String((config && config.model) || '').toLowerCase(); + const window = Object.keys(FAMILY_CONTEXT_WINDOWS).reduce( + (found, family) => + found || (model.includes(family) ? FAMILY_CONTEXT_WINDOWS[family] : 0), + 0 + ); + return window || DEFAULT_CONTEXT_WINDOW; +}; + +/** + * Input-token budget for a request: the context window minus the output + * allowance. + * + * Without an explicit `maxTokens` that allowance is half the window + * (conservative: local models are small and the caller may not have capped + * the reply). With one, the request sends exactly that `max_tokens`, so + * reserving the default half instead would throw away half the window on a + * model the user has already told us to keep short — a 512-token cap on a + * 32k model would leave the history budget at 16k rather than 32k minus 512. + * + * Clamped to half the window so an oversized `maxTokens` cannot drive the + * input budget to zero. Tool definitions are subtracted separately (see + * getMessageBudget): they are not part of this reserve. + */ +export const getTokenBudget = (config?: ?CustomAIConfig): number => { + const fullWindow = getContextWindow(config); + const defaultReserve = Math.floor(fullWindow / 2); + const requested = + config && typeof config.maxTokens === 'number' && config.maxTokens > 0 + ? config.maxTokens + : 0; + const outputReserve = requested + ? Math.min(requested, defaultReserve) + : defaultReserve; + return fullWindow - outputReserve; +}; + +/** + * Estimate the size of the tool definitions sent alongside a request. + * + * `tools` ride on the same request as the messages and consume the same + * context window, but they are not messages. The built-in toolset serializes + * to roughly 6.5k tokens, which is more than a llama-family model's entire + * message budget — budgeting messages alone therefore sends a request that is + * already over the window before a single line of history is counted. + */ +export const estimateToolsTokens = (tools?: ?Array): number => + tools && tools.length ? estimateTokens(JSON.stringify(tools)) : 0; + +// The messages must keep at least this much room after the tools are +// accounted for. Without a floor, a model whose window cannot hold the tool +// schema would get a zero budget and lose its system prompt and history +// entirely — a worse outcome than sending an oversized request. The tools are +// still sent (the model needs them); the shortfall surfaces through the +// server's own context-length error rather than as silent amnesia. +const MIN_MESSAGE_BUDGET = 1024; + +/** + * Message budget once the tool definitions sharing the window are subtracted. + */ +export const getMessageBudget = ( + config: ?CustomAIConfig, + tools?: ?Array +): number => + Math.max( + MIN_MESSAGE_BUDGET, + getTokenBudget(config) - estimateToolsTokens(tools) + ); + +/** + * Estimate the prompt size of a full OpenAI-style message list. + */ +export const estimateMessagesTokens = (openAiMessages: Array): number => + openAiMessages.reduce((sum, message) => { + if (!message) return sum; + const content = message.content; + const contentTokens = + typeof content === 'string' + ? estimateTokens(content) + : estimateTokens(content ? JSON.stringify(content) : ''); + // A tool call costs more than its arguments: the model also receives the + // name and id, and a `role: 'tool'` reply carries the call id back. Those + // were ignored, so a transcript of 30 tool exchanges under-counted by + // ~80% (150 vs 810 tokens) — an estimate that reads as comfortably inside + // the budget while the request is over it. Tool names are long here + // (`change_scene_properties_layers_effects_groups`), so this is not + // rounding noise. + let toolCallTokens = 0; + // `tool_calls` comes from a network response or a persisted request and is + // not shape-validated, so `|| []` is not enough: a truthy non-array (an + // object, a number) is not iterable and threw out of the budget + // calculation, breaking the whole turn. A malformed field simply costs + // nothing. + const toolCalls = Array.isArray(message.tool_calls) + ? message.tool_calls + : []; + for (const toolCall of toolCalls) { + if (!toolCall) continue; + const fn = toolCall.function || {}; + toolCallTokens += + estimateTokens(toolCall.id) + + estimateTokens(fn.name) + + estimateTokens(fn.arguments); + } + const toolOutputTokens = estimateTokens(message.tool_call_id); + return sum + contentTokens + toolCallTokens + toolOutputTokens; + }, 0); + +const SYSTEM_STRUCTURE_MARKER = 'Current Project Structure:\n'; +const SYSTEM_EXTENSIONS_MARKER = 'Installed Project Extensions:'; +const SYSTEM_TRUNCATION_NOTE = + '\n[... project structure truncated to fit the model context window ...]\n'; + +const hardCapSystemContent = (content: string, maxTokens: number): string => { + const bodyBudget = Math.max( + 16, + maxTokens - estimateTokens(SYSTEM_TRUNCATION_NOTE) + ); + const body = sliceToTokenBudget(content, bodyBudget); + if (body.length === content.length) return content; + return body + SYSTEM_TRUNCATION_NOTE; +}; + +/** + * The system prompt embeds the full project structure JSON. History dropping + * never touches `role: 'system'`, so a create/first turn on a small local + * model (e.g. llama → 4096-token budget) can ship an oversized system message + * alone. Truncate the structure section first (keep guidelines + extensions), + * then hard-cap the whole prompt if still over. Returns the same array + * reference when nothing needed compaction. + */ +export const compactSystemMessageToBudget = ( + openAiMessages: Array, + budget: number +): Array => { + let sysIndex = -1; + for (let i = 0; i < openAiMessages.length; i++) { + const message = openAiMessages[i]; + if (message && message.role === 'system') { + sysIndex = i; + break; + } + } + if (sysIndex < 0) return openAiMessages; + const systemMessage = openAiMessages[sysIndex]; + if (!systemMessage || typeof systemMessage.content !== 'string') { + return openAiMessages; + } + + // Leave room for the newest exchange so compaction cannot starve the + // user message that triggered the turn. + const RESERVE_FOR_EXCHANGE = 256; + const maxSystemTokens = Math.max(128, budget - RESERVE_FOR_EXCHANGE); + const content = systemMessage.content; + if (estimateTokens(content) <= maxSystemTokens) return openAiMessages; + + const structureStart = content.indexOf(SYSTEM_STRUCTURE_MARKER); + let nextContent: string; + if (structureStart >= 0) { + const bodyStart = structureStart + SYSTEM_STRUCTURE_MARKER.length; + const extensionsStart = content.indexOf( + SYSTEM_EXTENSIONS_MARKER, + bodyStart + ); + const bodyEnd = extensionsStart >= 0 ? extensionsStart : content.length; + const prefix = content.slice(0, bodyStart); + const body = content.slice(bodyStart, bodyEnd); + const suffix = content.slice(bodyEnd); + const overhead = + estimateTokens(prefix) + + estimateTokens(suffix) + + estimateTokens(SYSTEM_TRUNCATION_NOTE); + const allowedBodyTokens = Math.max(64, maxSystemTokens - overhead); + const allowedBody = sliceToTokenBudget(body, allowedBodyTokens); + nextContent = + allowedBody.length < body.length + ? prefix + allowedBody + SYSTEM_TRUNCATION_NOTE + suffix + : hardCapSystemContent(content, maxSystemTokens); + } else { + nextContent = hardCapSystemContent(content, maxSystemTokens); + } + if (estimateTokens(nextContent) > maxSystemTokens) { + nextContent = hardCapSystemContent(nextContent, maxSystemTokens); + } + if (nextContent === content) return openAiMessages; + + const messages = openAiMessages.slice(); + messages[sysIndex] = { ...systemMessage, content: nextContent }; + return messages; +}; + +/** + * Drop oldest non-system messages (after the system prompt) until the + * estimated prompt fits the budget. Tool outputs follow their assistant + * `tool_calls` message: dropping one drops its outputs too, so a server + * never sees an orphan tool message. The last two messages are always kept. + * An oversized system prompt (embedded project JSON) is compacted first — + * history dropping never touches `role: 'system'`. + */ +export const trimMessagesToBudget = ( + openAiMessages: Array, + budget: number +): Array => { + let messages = compactSystemMessageToBudget(openAiMessages, budget); + let estimate = estimateMessagesTokens(messages); + if (estimate <= budget) return messages; + + // First pass: compact the biggest oversized message contents in place + // rather than dropping them — tool outputs keep their pairing and the + // model keeps knowing the tool ran. The newest exchange and the system + // prompt are never touched. + const COMPACT_THRESHOLD = 4000; // ~1000 tokens + const KEPT_HEAD = 125; // tokens of the message to keep as a head. + for (;;) { + if (estimate <= budget) return messages; + let largestIndex = -1; + let largestSize = 0; + for (let i = 1; i < messages.length - 2; i++) { + const message = messages[i]; + if (!message || message.role === 'system') continue; + if (typeof message.content !== 'string') continue; + if (message.content.length <= COMPACT_THRESHOLD) continue; + if (message.content.length > largestSize) { + largestSize = message.content.length; + largestIndex = i; + } + } + if (largestIndex === -1) break; + const message = messages[largestIndex]; + const keptHead = sliceToTokenBudget(message.content, KEPT_HEAD); + const removed = message.content.length - keptHead.length; + const nextContent = + keptHead + + `\n[... trimmed: ${removed} characters removed to fit the model context window ...]`; + messages = messages.slice(); + messages[largestIndex] = { ...message, content: nextContent }; + // Decrement by what the estimator actually charged for the removed text + // rather than assuming a quarter-token per character: a CJK message costs + // a full token per character, so the flat rule left the running estimate + // inflated and every later message was dropped unnecessarily. + estimate -= estimateTokens(message.content) - estimateTokens(nextContent); + } + const kept = []; + let dropToolOutputs = false; + for (let i = 0; i < messages.length; i++) { + const message = messages[i]; + const isProtected = + i === 0 || + i >= messages.length - 2 || + (message && message.role === 'system'); + if (isProtected) { + kept.push(message); + continue; + } + if (!message) { + // Same null tolerance estimateMessagesTokens and the isProtected guard + // above already have: a hole in the list must not crash the trim. + continue; + } + if (estimate > budget && !dropToolOutputs) { + const toolCalls = message.role === 'assistant' && message.tool_calls; + if (toolCalls && toolCalls.length > 0) { + dropToolOutputs = true; + } + estimate -= estimateTokens( + typeof message.content === 'string' + ? message.content + : JSON.stringify(message.content || '') + ); + continue; + } + if (dropToolOutputs) { + if (message.role === 'tool') { + // Output of a dropped tool_calls assistant: also dropped — its + // tokens must still be accounted for, or the estimate stays + // inflated and later messages are over-dropped. + estimate -= estimateTokens( + typeof message.content === 'string' + ? message.content + : JSON.stringify(message.content || '') + ); + continue; + } + dropToolOutputs = false; + } + kept.push(message); + } + return kept; +}; + +/** Reset CustomAIClient state (for testing). */ +export const _resetCustomAiClientForTesting = () => { + providerRetryDelayMs = PROVIDER_RETRY_DELAY_MS; + cachedConfig = null; + for (const key of Object.keys(localAiRequestsCache)) { + delete localAiRequestsCache[key]; + } + for (const key of Object.keys(localAiRequestAbortControllers)) { + delete localAiRequestAbortControllers[key]; + } + for (const key of Object.keys(pendingCreateAiRequestIds)) { + delete pendingCreateAiRequestIds[key]; + } + createAbortRequested = false; + for (const key of Object.keys(localAiRequestTrimCounts)) { + delete localAiRequestTrimCounts[key]; + } + for (const key of Object.keys(localAiRequestSystemCompacted)) { + delete localAiRequestSystemCompacted[key]; + } + for (const key of Object.keys(localAiRequestTokenTotals)) { + delete localAiRequestTokenTotals[key]; + } + for (const key of Object.keys(localAiRequestContextTokens)) { + delete localAiRequestContextTokens[key]; + } + for (const key of Object.keys(localAiRequestProviderTelemetry)) { + delete localAiRequestProviderTelemetry[key]; + } + for (const key of Object.keys(localAiRequestModelOverrides)) { + delete localAiRequestModelOverrides[key]; + } + for (const key of Object.keys(localAiRequestPartialContent)) { + delete localAiRequestPartialContent[key]; + } + for (const key of Object.keys(localAiTurnTails)) { + delete localAiTurnTails[key]; + } +}; + +/** + * Load local AI requests from local storage. + * Only merges plain-object entries that look like an AiRequest — a corrupt + * array/scalar JSON payload or a non-object value must not poison the cache + * (Object.assign of `null`/scalars creates keys that crash list sorting and + * status mapping on the next customGetAiRequests call). + */ +/** + * Normalize a persisted request's message content types on read. + * + * Chats saved before the local parser emitted `output_text` carry `type: + * 'text'` for their answer entries. Nothing renders that type (ChatMessages + * returns null and RenderItem's union does not admit it), so an existing chat + * would show no answers after upgrading. Rewriting on read heals the stored + * copy the next time it is saved, with no format version or migration pass. + */ +const normalizePersistedMessages = (request: AiRequest): AiRequest => { + const output = request.output; + // A persisted request's `output` is not validated by the loader (which checks + // only id/status), so a truthy non-array reaches every consumer: a spread or + // `.map` over it throws 'is not iterable' / 'is not a function' and fails the + // whole turn. Guaranteeing an array here fixes all of them at one choke point, + // since this runs on every request loaded from storage. + if (!Array.isArray(output)) return { ...request, output: [] }; + let changed = false; + const normalizedOutput = []; + for (const message of output) { + if ( + !message || + message.type !== 'message' || + !Array.isArray(message.content) + ) { + normalizedOutput.push(message); + continue; + } + let messageChanged = false; + const content = []; + for (const entry of message.content) { + if (entry && entry.type === 'text') { + messageChanged = true; + content.push({ + ...entry, + type: 'output_text', + annotations: Array.isArray(entry.annotations) + ? entry.annotations + : [], + }); + } else { + content.push(entry); + } + } + if (!messageChanged) { + normalizedOutput.push(message); + continue; + } + changed = true; + normalizedOutput.push({ ...message, content }); + } + return changed ? { ...request, output: normalizedOutput } : request; +}; +export const loadLocalAiRequests = (): { [id: string]: AiRequest } => { + try { + if (typeof localStorage !== 'undefined') { + const persisted = localStorage.getItem(LOCAL_STORAGE_REQUESTS_KEY); + if (persisted) { + const parsed = JSON.parse(persisted); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const entries = Object.entries(parsed); + for (let i = 0; i < entries.length; i++) { + const key = entries[i][0]; + const value = entries[i][1]; + if ( + value && + typeof value === 'object' && + !Array.isArray(value) && + typeof value.id === 'string' && + typeof value.status === 'string' + ) { + localAiRequestsCache[key] = (normalizePersistedMessages( + (value: any) + ): any); + } + } + } + } + } + } catch (err) { + console.warn('Error loading local AI requests from localStorage:', err); + } + return localAiRequestsCache; +}; + +/** + * Save local AI requests to local storage, retaining a bounded number of requests + * and excluding full gameProjectJson to prevent storage quota exhaustion. + * Quota/security errors are swallowed so a write failure never aborts the + * in-memory create/update/delete path that called us. + */ +export const saveLocalAiRequests = () => { + if (typeof localStorage === 'undefined') return; + + try { + // Keep the most recently *updated* chats, not the most recently created: + // object key order is insertion order, so re-assigning an older chat after + // a new turn leaves it in its original slot. Slicing on that order would + // drop a newer conversation from persistence while keeping a stale one — + // continuing an old chat could make a recent one disappear on reload. + const recentKeys = Object.keys(localAiRequestsCache) + .filter(key => localAiRequestsCache[key]) + // Sub-agent children are internal and already excluded from the chat + // history; persisting them would consume the recency budget and evict + // real chats after a multi-agent run. + .filter(key => !localAiRequestsCache[key].parentAiRequestId) + .sort((a, b) => { + const aAt = new Date(localAiRequestsCache[a].updatedAt).getTime(); + const bAt = new Date(localAiRequestsCache[b].updatedAt).getTime(); + // An unparsable/missing timestamp sorts last rather than scrambling. + const safeA = Number.isFinite(aAt) ? aAt : 0; + const safeB = Number.isFinite(bAt) ? bAt : 0; + return safeB - safeA; + }) + .slice(0, MAX_LOCAL_SAVED_REQUESTS); + const persistableMap: { [id: string]: AiRequest } = {}; + + for (const key of recentKeys) { + const req = localAiRequestsCache[key]; + if (req) { + const { gameProjectJson, ...persistableReq } = req; + persistableMap[key] = (persistableReq: any); + } + } + + localStorage.setItem( + LOCAL_STORAGE_REQUESTS_KEY, + JSON.stringify(persistableMap) + ); + + // Sub-agent children get their own bounded store. They are internal + // (excluded from the history and from the parent recency budget), but the + // per-agent dashboard's transcript needs them after a reload. + const recentChildKeys = Object.keys(localAiRequestsCache) + .filter(key => { + const req = localAiRequestsCache[key]; + return req && req.parentAiRequestId; + }) + .sort((a, b) => { + const aAt = new Date(localAiRequestsCache[a].updatedAt).getTime(); + const bAt = new Date(localAiRequestsCache[b].updatedAt).getTime(); + const safeA = Number.isFinite(aAt) ? aAt : 0; + const safeB = Number.isFinite(bAt) ? bAt : 0; + return safeB - safeA; + }) + .slice(0, MAX_LOCAL_SAVED_SUBAGENT_REQUESTS); + const persistableChildMap: { [id: string]: AiRequest } = {}; + for (const key of recentChildKeys) { + const req = localAiRequestsCache[key]; + if (req) { + const { gameProjectJson, ...persistableReq } = req; + persistableChildMap[key] = (persistableReq: any); + } + } + localStorage.setItem( + LOCAL_STORAGE_SUBAGENT_REQUESTS_KEY, + JSON.stringify(persistableChildMap) + ); + } catch (err) { + console.warn('Error saving local AI requests to localStorage:', err); + } +}; + +/** + * Load the persisted sub-agent requests into the same cache as their parents. + * Called at startup after `loadLocalAiRequests`; a corrupt entry is skipped by + * the same id/status check. + */ +export const loadLocalAiSubAgentRequests = (): void => { + try { + if (typeof localStorage === 'undefined') return; + const persisted = localStorage.getItem(LOCAL_STORAGE_SUBAGENT_REQUESTS_KEY); + if (!persisted) return; + const parsed = JSON.parse(persisted); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return; + const entries = Object.entries(parsed); + for (let i = 0; i < entries.length; i++) { + const key = entries[i][0]; + const value = entries[i][1]; + if ( + value && + typeof value === 'object' && + !Array.isArray(value) && + typeof value.id === 'string' && + typeof value.status === 'string' && + typeof value.parentAiRequestId === 'string' + ) { + localAiRequestsCache[key] = (normalizePersistedMessages( + (value: any) + ): any); + } + } + } catch (err) { + console.warn( + 'Error loading local AI sub-agent requests from localStorage:', + err + ); + } +}; + +/** Exposed so a test can simulate a fresh session's load. */ +export const loadLocalAiSubAgentRequestsForTesting = (): void => + loadLocalAiSubAgentRequests(); + +// Initialize requests, per-chat model choices and token meters from localStorage +loadLocalAiRequests(); +loadLocalAiSubAgentRequests(); +loadLocalAiRequestModelOverrides(); +loadLocalAiRequestTokenTotals(); + +/** + * OpenAI Tool definitions for GDevelop Editor Functions. + */ +export const GDEVELOP_OPENAI_TOOLS: Array<{| + type: 'function', + function: {| + name: string, + description: string, + parameters: Object, + |}, +|}> = [ + { + type: 'function', + function: { + name: 'create_scene', + description: + 'Create a new scene in the GDevelop project with the given name.', + parameters: { + type: 'object', + properties: { + scene_name: { + type: 'string', + description: 'Name of the new scene to create.', + }, + }, + required: ['scene_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'create_or_replace_object', + description: + 'Create or replace an object in a scene (or global if scene_name is omitted).', + parameters: { + type: 'object', + properties: { + object_name: { + type: 'string', + description: 'Name of the object.', + }, + object_type: { + type: 'string', + description: + 'Type of object (e.g. "Sprite", "TiledSpriteObject::TiledSprite", "TextObject::Text", "PanelSpriteObject::PanelSprite", "Tilemap::Tilemap", "Scene3D::Cube3D").', + }, + scene_name: { + type: 'string', + description: + 'Name of the scene. If omitted or empty, creates a global object.', + }, + description: { + type: 'string', + description: 'Optional description of the object purpose.', + }, + }, + required: ['object_name', 'object_type'], + }, + }, + }, + { + type: 'function', + function: { + name: 'add_behavior', + description: 'Add a behavior to an object in the project.', + parameters: { + type: 'object', + properties: { + object_name: { + type: 'string', + description: 'Name of the object to add behavior to.', + }, + behavior_name: { + type: 'string', + description: + 'Unique name for this behavior instance on the object.', + }, + behavior_type: { + type: 'string', + description: + 'Type of behavior (e.g. "PlatformBehavior::PlatformerObjectBehavior", "PlatformBehavior::PlatformBehavior", "TopDownMovementBehavior::TopDownMovementBehavior", "Physics2::Physics2Behavior", "DestroyOutsideBehavior::DestroyOutside", "SmoothCamera::SmoothCamera").', + }, + scene_name: { + type: 'string', + description: + 'Name of the scene (if object is a scene object). Omit for global objects.', + }, + }, + required: ['object_name', 'behavior_name', 'behavior_type'], + }, + }, + }, + { + type: 'function', + function: { + name: 'put_2d_instances', + description: + 'Place one or more 2D instances of objects into a scene at specified coordinates.', + parameters: { + type: 'object', + properties: { + scene_name: { + type: 'string', + description: 'Name of the scene.', + }, + instances: { + type: 'array', + items: { + type: 'object', + properties: { + object_name: { + type: 'string', + description: 'Name of the object to instantiate.', + }, + x: { type: 'number', description: 'X position in pixels.' }, + y: { type: 'number', description: 'Y position in pixels.' }, + layer_name: { + type: 'string', + description: + 'Layer name (default empty string for base layer).', + }, + angle: { + type: 'number', + description: 'Angle in degrees (default 0).', + }, + z_order: { type: 'number', description: 'Z-order.' }, + }, + required: ['object_name', 'x', 'y'], + }, + description: 'List of instances to place.', + }, + }, + required: ['scene_name', 'instances'], + }, + }, + }, + { + type: 'function', + function: { + name: 'add_scene_events', + description: + 'Generate or modify scene events in GDevelop based on natural language description.', + parameters: { + type: 'object', + properties: { + scene_name: { + type: 'string', + description: 'Name of the scene whose events are being modified.', + }, + events_description: { + type: 'string', + description: + 'Detailed natural language description of the events logic to implement.', + }, + objects_list: { + type: 'string', + description: + 'Comma-separated list of objects involved in these events.', + }, + extension_names_list: { + type: 'string', + description: + 'Comma-separated list of extensions/behaviors used (e.g. "PlatformBehavior::PlatformerObjectBehavior, Keyboard, Scene").', + }, + }, + required: ['scene_name', 'events_description'], + }, + }, + }, + { + type: 'function', + function: { + name: 'run_script', + description: + 'Execute a JavaScript script in the GDevelop editor to inspect or modify the project. Inside the script, the editor functions are plain async functions in scope: call them with `await`, e.g. `await create_or_replace_object({...})` (a `functions` namespace alias with the same functions also exists). `console.log` lines and the return value are reported back. Nothing else is in scope: browser and engine globals (window, document, gd, fetch, localStorage) are shadowed or cannot reach the open project, so never probe for them - call an exposed function directly. A ReferenceError names the functions available in the script.', + parameters: { + type: 'object', + properties: { + js_code: { + type: 'string', + description: + 'JavaScript code to execute in the editor environment. Has access to exposed editor functions and project APIs.', + }, + title: { + type: 'string', + description: + 'Short human-readable title for this script, shown in the chat.', + }, + script: { + type: 'string', + description: + 'Deprecated alias for js_code. Prefer js_code; script is accepted for compatibility and mapped to js_code.', + }, + }, + required: ['js_code'], + }, + }, + }, + { + type: 'function', + function: { + name: 'read_events_source', + description: + 'Read the current events of a scene in EventScript source format.', + parameters: { + type: 'object', + properties: { + scene_name: { + type: 'string', + description: 'Name of the scene.', + }, + }, + required: ['scene_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'describe_instances', + description: 'List all instances present in a scene.', + parameters: { + type: 'object', + properties: { + scene_name: { + type: 'string', + description: 'Name of the scene.', + }, + filter_by_object_name: { + type: 'string', + description: + 'Optional comma-separated list of object names to filter by.', + }, + }, + required: ['scene_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'inspect_object_properties_effects', + description: 'Inspect properties and effects of an object.', + parameters: { + type: 'object', + properties: { + object_name: { type: 'string', description: 'Name of the object.' }, + scene_name: { + type: 'string', + description: 'Scene name if scene object.', + }, + }, + required: ['object_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'inspect_behavior_properties', + description: 'Inspect properties of a behavior attached to an object.', + parameters: { + type: 'object', + properties: { + object_name: { type: 'string', description: 'Name of the object.' }, + behavior_name: { + type: 'string', + description: 'Name of the behavior instance.', + }, + scene_name: { + type: 'string', + description: 'Scene name if scene object.', + }, + }, + required: ['object_name', 'behavior_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'inspect_scene_properties_layers_effects', + description: 'Inspect properties, layers, and effects of a scene.', + parameters: { + type: 'object', + properties: { + scene_name: { type: 'string', description: 'Name of the scene.' }, + }, + required: ['scene_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'change_scene_properties_layers_effects_groups', + description: + 'Change properties of a scene (such as backgroundColor, resolution, name, firstScene), layers, effects, or object groups.', + parameters: { + type: 'object', + properties: { + scene_name: { + type: 'string', + description: 'Name of the scene.', + }, + changed_properties: { + type: 'array', + items: { + type: 'object', + properties: { + property_name: { + type: 'string', + description: + 'Name of the scene property: "backgroundColor" (hex string like "#3498db" or "rgb(52, 152, 219)"), "gameResolutionWidth", "gameResolutionHeight", "name", "isFirstScene", "stopSoundsOnStartup", "gameOrientation", "gameScaleMode", "gameName".', + }, + new_value: { + type: 'string', + description: 'The new value for the property as a string.', + }, + }, + required: ['property_name', 'new_value'], + }, + description: 'List of scene properties to update.', + }, + changed_layers: { + type: 'array', + items: { + type: 'object', + properties: { + layer_name: { type: 'string' }, + new_layer_name: { type: 'string' }, + is_lighting_layer: { type: 'boolean' }, + is_follow_base_layer_camera: { type: 'boolean' }, + delete_this_layer: { type: 'boolean' }, + }, + required: ['layer_name'], + }, + description: 'List of layers to modify, create or remove.', + }, + changed_layer_effects: { + type: 'array', + description: + 'Effects of a layer to add, update, rename, move or remove.', + items: { + type: 'object', + properties: { + layer_name: { type: 'string' }, + effect_name: { type: 'string' }, + effect_type: { type: 'string' }, + new_effect_name: { type: 'string' }, + new_effect_position: { type: 'number' }, + delete_this_effect: { type: 'boolean' }, + changed_properties: { + type: 'array', + items: { + type: 'object', + properties: { + property_name: { type: 'string' }, + new_value: { type: 'string' }, + }, + required: ['property_name', 'new_value'], + }, + }, + }, + required: ['layer_name', 'effect_name'], + }, + }, + changed_groups: { + type: 'array', + description: + 'Object groups of the scene (or custom object) to create, rename, or change membership of.', + items: { + type: 'object', + properties: { + group_name: { type: 'string' }, + new_group_name: { type: 'string' }, + delete_this_group: { type: 'boolean' }, + objects_to_add: { + type: 'array', + items: { type: 'string' }, + }, + objects_to_remove: { + type: 'array', + items: { type: 'string' }, + }, + }, + required: ['group_name'], + }, + }, + move_instances: { + type: 'object', + description: + 'Move instances of this scene to another scene or external layout, optionally filtered.', + properties: { + to_scope: { + type: 'object', + description: + 'Target scope: { type: "scene", scene_name } or { type: "external_layout", external_layout_name }.', + }, + filter_by_object_names: { + type: 'array', + items: { type: 'string' }, + }, + filter_by_layer_names: { + type: 'array', + items: { type: 'string' }, + }, + instance_ids: { + type: 'array', + items: { type: 'string' }, + }, + }, + required: ['to_scope'], + }, + delete_this_scene: { + type: 'boolean', + description: 'Set to true to delete this scene.', + }, + }, + required: ['scene_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'change_object_properties_effects', + description: 'Change properties and visual effects of an object.', + parameters: { + type: 'object', + properties: { + object_name: { type: 'string', description: 'Name of the object.' }, + scene_name: { + type: 'string', + description: 'Name of the scene (omit for global object).', + }, + changed_properties: { + type: 'array', + items: { + type: 'object', + properties: { + property_name: { type: 'string' }, + new_value: { type: 'string' }, + }, + required: ['property_name', 'new_value'], + }, + }, + changed_effects: { + type: 'array', + description: + 'Visual effects of the object to add, update, rename, move or remove.', + items: { + type: 'object', + properties: { + effect_name: { type: 'string' }, + effect_type: { type: 'string' }, + new_effect_name: { type: 'string' }, + new_effect_position: { type: 'number' }, + delete_this_effect: { type: 'boolean' }, + changed_properties: { + type: 'array', + items: { + type: 'object', + properties: { + property_name: { type: 'string' }, + new_value: { type: 'string' }, + }, + required: ['property_name', 'new_value'], + }, + }, + }, + required: ['effect_name'], + }, + }, + delete_this_object: { type: 'boolean' }, + }, + required: ['object_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'change_behavior_property', + description: 'Change a property of a behavior attached to an object.', + parameters: { + type: 'object', + properties: { + object_name: { type: 'string', description: 'Name of the object.' }, + behavior_name: { + type: 'string', + description: 'Name of the behavior instance.', + }, + changed_properties: { + type: 'array', + items: { + type: 'object', + properties: { + property_name: { type: 'string' }, + new_value: { type: 'string' }, + }, + required: ['property_name', 'new_value'], + }, + description: + 'Behavior properties to set. Each item names a property and its new value as a string.', + }, + scene_name: { + type: 'string', + description: 'Scene name if scene object.', + }, + delete_this_behavior: { + type: 'boolean', + description: 'Set to true to remove this behavior.', + }, + }, + required: ['object_name', 'behavior_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'change_project_properties_resources', + description: + 'Change global project settings such as window resolution, orientation, scale mode, or game name.', + parameters: { + type: 'object', + properties: { + changed_properties: { + type: 'array', + items: { + type: 'object', + properties: { + property_name: { + type: 'string', + description: + 'Property name: "gameResolutionWidth", "gameResolutionHeight", "gameOrientation", "gameScaleMode", "gameName", "packageName", "version", "author", "loadingScreenBackgroundColor".', + }, + new_value: { type: 'string' }, + }, + required: ['property_name', 'new_value'], + }, + description: 'List of project properties to update.', + }, + changed_resources: { + type: 'array', + description: + 'Project resources to rename or remove (e.g. an audio file).', + items: { + type: 'object', + properties: { + resource_name: { type: 'string' }, + new_resource_name: { type: 'string' }, + delete_this_resource: { type: 'boolean' }, + }, + required: ['resource_name'], + }, + }, + }, + }, + }, + }, + { + type: 'function', + function: { + name: 'inspect_project_properties_resources', + description: 'Inspect project global properties and resources list.', + parameters: { + type: 'object', + properties: {}, + }, + }, + }, + { + type: 'function', + function: { + name: 'add_or_edit_variable', + description: 'Add or edit a variable at project, scene, or object level.', + parameters: { + type: 'object', + properties: { + variable_scope: { + type: 'string', + enum: ['global', 'scene', 'object', 'group', 'instance'], + description: 'Scope of the variable ("global", "scene", "object").', + }, + scope: { + type: 'string', + enum: ['global', 'scene', 'object', 'group', 'instance'], + description: 'Alias for variable_scope.', + }, + variable_name_or_path: { + type: 'string', + description: + 'Variable name or path (e.g. "Score" or "Player.Health").', + }, + name: { + type: 'string', + description: 'Alias for variable_name_or_path.', + }, + variable_type: { + type: 'string', + enum: ['number', 'string', 'boolean', 'structure', 'array'], + description: 'Variable type.', + }, + type: { + type: 'string', + enum: ['number', 'string', 'boolean', 'structure', 'array'], + description: 'Alias for variable_type.', + }, + value: { description: 'Initial or updated value for the variable.' }, + scene_name: { + type: 'string', + description: 'Scene name if scope is scene or object.', + }, + object_name: { + type: 'string', + description: 'Object name if scope is object.', + }, + delete_this_variable: { + type: 'boolean', + description: 'Set to true to delete this variable.', + }, + variables: { + type: 'array', + items: { + type: 'object', + properties: { + variable_name_or_path: { type: 'string' }, + name: { type: 'string' }, + value: { type: 'string' }, + variable_type: { type: 'string' }, + type: { type: 'string' }, + delete_this_variable: { type: 'boolean' }, + }, + }, + description: 'Optional batch of variable operations.', + }, + }, + }, + }, + }, + { + type: 'function', + function: { + name: 'put_3d_instances', + description: + 'Place 3D instances of objects into a scene at specified 3D coordinates.', + parameters: { + type: 'object', + properties: { + scene_name: { type: 'string', description: 'Name of the scene.' }, + object_name: { + type: 'string', + description: 'Name of the 3D object.', + }, + layer_name: { + type: 'string', + description: 'Layer name (default "").', + }, + brush_kind: { + type: 'string', + enum: ['create', 'erase', 'modify'], + description: 'Action to perform.', + }, + brush_position: { + type: 'string', + description: 'Position "x, y, z" in 3D space.', + }, + new_instances_count: { + type: 'number', + description: 'Number of instances to create (default 1).', + }, + }, + required: ['scene_name', 'layer_name', 'brush_kind'], + }, + }, + }, + { + type: 'function', + function: { + name: 'inspect_variables', + description: 'Inspect variables at global, scene, or object level.', + parameters: { + type: 'object', + properties: { + variable_scope: { + type: 'string', + enum: ['global', 'scene', 'object'], + description: 'Scope of the variables to inspect.', + }, + scene_name: { + type: 'string', + description: 'Name of the scene (if scope is scene or object).', + }, + object_name: { + type: 'string', + description: 'Name of the object (if scope is object).', + }, + variable_names_or_paths: { + type: 'array', + items: { type: 'string' }, + description: + 'Optional list of specific variable names or paths to inspect.', + }, + }, + required: ['variable_scope'], + }, + }, + }, + { + type: 'function', + function: { + name: 'read_game_project_json', + description: 'Read the complete or partial game project structure JSON.', + parameters: { + type: 'object', + properties: { + section: { + type: 'string', + description: 'Optional section of project JSON to inspect.', + }, + }, + }, + }, + }, + { + type: 'function', + function: { + name: 'search_object_asset_store', + description: + 'Search the GDevelop asset store for 2D/3D objects and sprite packs.', + parameters: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Search term (e.g. "player", "coin", "platform").', + }, + category: { type: 'string', description: 'Optional asset category.' }, + }, + required: ['query'], + }, + }, + }, + { + type: 'function', + function: { + name: 'search_resource_store', + description: + 'Search the GDevelop resource store for audio sounds, music, and fonts.', + parameters: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Search term (e.g. "jump", "laser", "music").', + }, + resource_kind: { + type: 'string', + enum: ['audio', 'font', 'image'], + description: 'Kind of resource to search.', + }, + }, + required: ['query'], + }, + }, + }, + { + type: 'function', + function: { + name: 'search_docs', + description: + 'Search GDevelop documentation for functions, behaviors, and instructions.', + parameters: { + type: 'object', + properties: { + query: { type: 'string', description: 'Documentation search query.' }, + }, + required: ['query'], + }, + }, + }, + { + type: 'function', + function: { + name: 'read_full_docs', + description: + 'Read full documentation for specific GDevelop extensions or features.', + parameters: { + type: 'object', + properties: { + extension_names: { + type: 'string', + description: + 'Comma-separated names of extensions to read docs for.', + }, + }, + required: ['extension_names'], + }, + }, + }, + { + type: 'function', + function: { + name: 'initialize_project', + description: 'Initialize a new project from a starter template.', + parameters: { + type: 'object', + properties: { + project_name: { + type: 'string', + description: 'Name of the new project/game.', + }, + game_name: { type: 'string', description: 'Alias for project_name.' }, + name: { type: 'string', description: 'Alias for project_name.' }, + template_slug: { + type: 'string', + description: + 'Slug of the starter template (e.g. "empty", "platformer", "top-down").', + }, + also_read_existing_events: { + type: 'boolean', + description: 'Whether to read existing events from template.', + }, + }, + }, + }, + }, + { + type: 'function', + function: { + name: 'get_game_starter_summary', + description: + 'Get a summary of an example starter template to study its structure.', + parameters: { + type: 'object', + properties: { + template_slug: { + type: 'string', + description: 'Slug of the starter template.', + }, + }, + required: ['template_slug'], + }, + }, + }, + { + type: 'function', + function: { + name: 'create_or_update_plan', + description: 'Create or update the step-by-step orchestrator plan.', + parameters: { + type: 'object', + properties: { + tasks: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Task ID (e.g. "task_1").' }, + title: { type: 'string', description: 'Short task title.' }, + description: { type: 'string', description: 'Task details.' }, + status: { + type: 'string', + enum: ['pending', 'in_progress', 'done', 'voided'], + description: 'Task status.', + }, + dependsOn: { + type: 'array', + items: { type: 'string' }, + description: 'List of task IDs this task depends on.', + }, + }, + required: ['id', 'title', 'description', 'status'], + }, + }, + }, + required: ['tasks'], + }, + }, + }, + { + type: 'function', + function: { + name: 'run_gameplay_test', + description: + 'Run or save a gameplay test in the project or an extension.', + parameters: { + type: 'object', + properties: { + scope: { + type: 'object', + properties: { + type: { type: 'string', enum: ['project', 'extension'] }, + extension_name: { type: 'string' }, + }, + required: ['type'], + description: + 'Test scope ({ type: "project" } or { type: "extension", extension_name: "..." }).', + }, + test_name: { type: 'string', description: 'Name of the test.' }, + source: { + type: 'string', + description: 'Optional test JavaScript code.', + }, + persist: { + type: 'boolean', + description: 'Whether to save the test (default true).', + }, + timeout_ms: { + type: 'number', + description: 'Timeout in milliseconds.', + }, + }, + required: ['scope', 'test_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'change_gameplay_tests', + description: + 'Change properties of or delete gameplay tests in the project.', + parameters: { + type: 'object', + properties: { + scope: { + type: 'object', + properties: { + type: { type: 'string', enum: ['project', 'extension'] }, + extension_name: { type: 'string' }, + }, + required: ['type'], + }, + changes: { + type: 'array', + items: { + type: 'object', + properties: { + test_name: { type: 'string' }, + delete_this_test: { type: 'boolean' }, + changed_properties: { + type: 'array', + items: { + type: 'object', + properties: { + property_name: { type: 'string' }, + new_value: { type: 'string' }, + }, + required: ['property_name', 'new_value'], + }, + }, + }, + required: ['test_name'], + }, + }, + }, + required: ['scope', 'changes'], + }, + }, + }, + { + type: 'function', + function: { + name: 'create_extension', + description: + 'Create a new events-functions extension in the project. Optionally copy an existing one.', + parameters: { + type: 'object', + properties: { + extension_name: { + type: 'string', + description: + 'Name of the extension to create. It is made unique automatically if taken.', + }, + duplicated_extension_name: { + type: 'string', + description: + 'Name of an existing extension to copy into the new one. Omit to create an empty extension.', + }, + full_name: { + type: 'string', + description: 'Human-readable title shown in the editor.', + }, + short_description: { + type: 'string', + description: 'One-line description of the extension.', + }, + description: { + type: 'string', + description: 'Full description of the extension.', + }, + category: { type: 'string', description: 'Extension category.' }, + tags: { + type: 'string', + description: 'Comma-separated tags.', + }, + author: { type: 'string', description: 'Extension author.' }, + }, + required: ['extension_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'change_extension_properties', + description: + 'Change properties of an extension, its dependencies, or delete the extension.', + parameters: { + type: 'object', + properties: { + extension_name: { + type: 'string', + description: 'Name of the extension to change.', + }, + new_name: { + type: 'string', + description: 'New name for the extension, to rename it.', + }, + delete_this_extension: { + type: 'boolean', + description: 'Set to true to delete the extension.', + }, + delete_even_if_used: { + type: 'boolean', + description: + 'Allow deleting an extension that is still used. Only when delete_this_extension is true.', + }, + changed_properties: { + type: 'array', + description: 'Properties to set on the extension.', + items: { + type: 'object', + properties: { + property_name: { + type: 'string', + description: + 'One of: fullName, shortDescription, description, category, tags, version, author, helpPath, previewIconUrl, iconUrl, dimension.', + }, + new_value: { type: 'string' }, + }, + required: ['property_name', 'new_value'], + }, + }, + changed_dependencies: { + type: 'array', + description: 'Dependencies to create, edit or delete.', + items: { + type: 'object', + properties: { + dependency_name: { type: 'string' }, + dependency_type: { type: 'string' }, + new_name: { type: 'string' }, + export_name: { type: 'string' }, + version: { type: 'string' }, + delete_this_dependency: { type: 'boolean' }, + extra_settings: { + type: 'object', + description: 'Dependency-specific settings, as strings.', + }, + }, + required: ['dependency_name'], + }, + }, + }, + required: ['extension_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'create_custom_object', + description: + 'Create a custom object (an events-based object) in an extension, optionally as a copy of an existing one.', + parameters: { + type: 'object', + properties: { + extension_name: { + type: 'string', + description: 'Extension to create the custom object in.', + }, + custom_object_name: { + type: 'string', + description: 'Name of the new custom object.', + }, + duplicated_custom_object_name: { + type: 'string', + description: 'Existing custom object to copy.', + }, + duplicated_from_extension_name: { + type: 'string', + description: + 'Extension holding the custom object to copy. Defaults to extension_name.', + }, + area: { + type: 'object', + description: + 'Default area of the object, as numbers: minX, minY, minZ, maxX, maxY, maxZ.', + properties: { + minX: { type: 'number' }, + minY: { type: 'number' }, + minZ: { type: 'number' }, + maxX: { type: 'number' }, + maxY: { type: 'number' }, + maxZ: { type: 'number' }, + }, + }, + }, + required: ['extension_name', 'custom_object_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'change_custom_object', + description: + 'Change a custom object: rename, delete, change its settings, its variants or its properties.', + parameters: { + type: 'object', + properties: { + extension_name: { + type: 'string', + description: 'Extension holding the custom object.', + }, + custom_object_name: { + type: 'string', + description: 'Name of the custom object to change.', + }, + new_name: { type: 'string', description: 'New name, to rename it.' }, + delete_this_custom_object: { + type: 'boolean', + description: 'Set to true to delete the custom object.', + }, + delete_even_if_used: { + type: 'boolean', + description: + 'Allow deleting a custom object that is still used. Only when delete_this_custom_object is true.', + }, + fit_area_to_children: { + type: 'string', + description: + 'Whether to fit the area to the children (true/false).', + }, + changed_settings: { + type: 'array', + description: 'Settings to change on the custom object.', + items: { + type: 'object', + properties: { + setting_name: { type: 'string' }, + new_value: { + description: + 'String, boolean or number depending on the setting.', + }, + }, + required: ['setting_name', 'new_value'], + }, + }, + changed_variants: { + type: 'array', + description: 'Variants to create or delete.', + items: { + type: 'object', + properties: { + variant_name: { type: 'string' }, + duplicated_from_variant_name: { type: 'string' }, + delete_this_variant: { type: 'boolean' }, + }, + required: ['variant_name'], + }, + }, + changed_properties: { + type: 'array', + description: 'Properties to set on the custom object.', + items: { + type: 'object', + properties: { + property_name: { type: 'string' }, + new_value: { type: 'string' }, + }, + required: ['property_name', 'new_value'], + }, + }, + }, + required: ['extension_name', 'custom_object_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'create_custom_behavior', + description: + 'Create a custom behavior (an events-based behavior) in an extension.', + parameters: { + type: 'object', + properties: { + extension_name: { + type: 'string', + description: 'Extension to create the behavior in.', + }, + custom_behavior_name: { + type: 'string', + description: 'Name of the new custom behavior.', + }, + duplicated_custom_behavior_name: { + type: 'string', + description: 'Existing custom behavior to copy.', + }, + duplicated_from_extension_name: { + type: 'string', + description: + 'Extension holding the behavior to copy. Defaults to extension_name.', + }, + full_name: { + type: 'string', + description: 'Human-readable title.', + }, + description: { type: 'string' }, + object_type: { + type: 'string', + description: + 'Restrict the behavior to objects of this type. Omit for all objects.', + }, + }, + required: ['extension_name', 'custom_behavior_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'change_custom_behavior', + description: + 'Change a custom behavior: rename, delete, change its settings, its shared properties or its properties.', + parameters: { + type: 'object', + properties: { + extension_name: { + type: 'string', + description: 'Extension holding the custom behavior.', + }, + custom_behavior_name: { + type: 'string', + description: 'Name of the custom behavior to change.', + }, + new_name: { type: 'string', description: 'New name, to rename it.' }, + delete_this_custom_behavior: { + type: 'boolean', + description: 'Set to true to delete the custom behavior.', + }, + delete_even_if_used: { + type: 'boolean', + description: + 'Allow deleting a behavior that is still used. Only when delete_this_custom_behavior is true.', + }, + changed_settings: { + type: 'array', + description: 'Settings to change on the custom behavior.', + items: { + type: 'object', + properties: { + setting_name: { type: 'string' }, + new_value: { + description: + 'String, boolean or number depending on the setting.', + }, + }, + required: ['setting_name', 'new_value'], + }, + }, + changed_shared_properties: { + type: 'array', + description: 'Shared properties of the behavior to change.', + items: { + type: 'object', + properties: { + property_name: { type: 'string' }, + new_value: { type: 'string' }, + }, + required: ['property_name', 'new_value'], + }, + }, + changed_properties: { + type: 'array', + description: 'Properties of the behavior to change.', + items: { + type: 'object', + properties: { + property_name: { type: 'string' }, + new_value: { type: 'string' }, + }, + required: ['property_name', 'new_value'], + }, + }, + }, + required: ['extension_name', 'custom_behavior_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'create_custom_function', + description: + 'Create a function in an extension, a custom object or a custom behavior.', + parameters: { + type: 'object', + properties: { + scope: { + type: 'object', + description: + 'Where to create the function. type "extension" needs extension_name; "custom_object" needs extension_name and custom_object_name; "custom_behavior" needs extension_name and custom_behavior_name.', + properties: { + type: { + type: 'string', + enum: ['extension', 'custom_object', 'custom_behavior'], + }, + extension_name: { type: 'string' }, + custom_object_name: { type: 'string' }, + custom_behavior_name: { type: 'string' }, + }, + required: ['type'], + }, + function_name: { + type: 'string', + description: 'Name of the new function.', + }, + duplicated_function_name: { + type: 'string', + description: 'Existing function to copy.', + }, + function_type: { + type: 'string', + description: + 'Action, Condition, Expression, StringExpression or ActionWithOperator.', + }, + getter_name: { + type: 'string', + description: + 'For ActionWithOperator: the getter function to build from.', + }, + full_name: { type: 'string' }, + description: { type: 'string' }, + group: { type: 'string' }, + sentence: { + type: 'string', + description: 'Sentence shown in the events sheet.', + }, + parameters: { + type: 'array', + description: 'Parameters to declare on the function.', + items: { type: 'object' }, + }, + }, + required: ['scope', 'function_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'change_custom_function', + description: + 'Change a function: rename, delete, change its settings or its parameters.', + parameters: { + type: 'object', + properties: { + scope: { + type: 'object', + description: + 'Where the function lives. type "extension" needs extension_name; "custom_object" needs extension_name and custom_object_name; "custom_behavior" needs extension_name and custom_behavior_name.', + properties: { + type: { + type: 'string', + enum: ['extension', 'custom_object', 'custom_behavior'], + }, + extension_name: { type: 'string' }, + custom_object_name: { type: 'string' }, + custom_behavior_name: { type: 'string' }, + }, + required: ['type'], + }, + function_name: { + type: 'string', + description: 'Name of the function to change.', + }, + new_name: { type: 'string', description: 'New name, to rename it.' }, + delete_this_function: { + type: 'boolean', + description: 'Set to true to delete the function.', + }, + changed_settings: { + type: 'array', + description: 'Settings to change on the function.', + items: { + type: 'object', + properties: { + setting_name: { type: 'string' }, + new_value: { + description: + 'String, boolean or number depending on the setting.', + }, + }, + required: ['setting_name', 'new_value'], + }, + }, + changed_parameters: { + type: 'array', + description: 'Parameters to change on the function.', + items: { type: 'object' }, + }, + }, + required: ['scope', 'function_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'inspect_extension', + description: + 'Read the structure of an extension: its custom objects, custom behaviors, functions and variants.', + parameters: { + type: 'object', + properties: { + extension_name: { + type: 'string', + description: 'Name of the extension to inspect.', + }, + custom_object_name: { + type: 'string', + description: 'Restrict the output to this custom object.', + }, + custom_behavior_name: { + type: 'string', + description: 'Restrict the output to this custom behavior.', + }, + function_name: { + type: 'string', + description: 'Restrict the output to this function.', + }, + variant_name: { + type: 'string', + description: 'Restrict the output to this variant.', + }, + }, + required: ['extension_name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'spawn_agent', + description: + 'Delegate one task to a studio sub-agent, which works on it and reports back. Only the studio lead may call this. Sub-agents never delegate further.', + parameters: { + type: 'object', + properties: { + role: { + type: 'string', + description: + 'Which specialist to use: "designer" writes the design document as GDD_ project variables; "developer" changes the project to implement a task; "tester" writes and runs a gameplay test and reports pass or fail.', + enum: ['designer', 'developer', 'tester'], + }, + short_title: { + type: 'string', + description: + 'A 2-5 word label shown in the chat for this sub-agent, e.g. "Build the town grid".', + }, + task: { + type: 'string', + description: + 'The complete, self-contained instruction for the sub-agent. It cannot see this conversation, so state everything it needs: the scene, the objects, the expected result.', + }, + context: { + type: 'string', + description: + 'Extra facts the sub-agent needs: relevant variable values, previous findings, constraints.', + }, + related_task_id: { + type: 'string', + description: + 'The id of the create_or_update_plan task this agent works on. That task is marked done when the agent reports back.', + }, + }, + required: ['role', 'short_title', 'task'], + }, + }, + }, +]; + +/** + * Extract thinking / reasoning content from text or OpenAI reasoning fields. + */ +export const extractThinkingAndContent = ( + rawContent: ?string, + reasoningContent: ?string +): {| + thinking: string | null, + cleanContent: string, + content: string, +|} => { + if (reasoningContent && reasoningContent.trim()) { + const clean = (rawContent || '').trim(); + return { + thinking: reasoningContent.trim(), + cleanContent: clean, + content: clean, + }; + } + + if (!rawContent) { + return { thinking: null, cleanContent: '', content: '' }; + } + + const thinkMatch = rawContent.match(/([\s\S]*?)<\/think>/i); + if (thinkMatch) { + const thinking = thinkMatch[1].trim(); + const cleanContent = rawContent + .replace(/[\s\S]*?<\/think>/i, '') + .trim(); + return { thinking: thinking || null, cleanContent, content: cleanContent }; + } + + // Truncated reasoning models can emit an unclosed tag + // (e.g. generation cut off mid-thought): everything after the opening tag + // is thinking, not content — otherwise it leaks into the chat and corrupts + // tool-call JSON parsing. + const unclosedIndex = rawContent.search(//i); + if ( + unclosedIndex !== -1 && + rawContent.toLowerCase().indexOf('') === -1 + ) { + const cleanContent = rawContent.slice(0, unclosedIndex).trim(); + const thinking = rawContent.slice(unclosedIndex + ''.length).trim(); + return { thinking: thinking || null, cleanContent, content: cleanContent }; + } + + const clean = rawContent.trim(); + return { thinking: null, cleanContent: clean, content: clean }; +}; + +/** + * Format GDevelop conversation messages into OpenAI standard format. + */ +export const transformGDevelopMessagesToOpenAi = ( + outputMessages: Array, + systemPromptOrGameProjectJson?: ?string, + projectSpecificExtensionsSummaryJson?: ?string, + mode?: string +): Array<{| + role: 'system' | 'user' | 'assistant' | 'tool', + content?: string | null, + tool_calls?: Array<{| + id: string, + type: 'function', + function: {| + name: string, + arguments: string, + |}, + |}>, + tool_call_id?: string, +|}> => { + const openAiMessages = []; + + let effectiveSystemPrompt: ?string = null; + if (systemPromptOrGameProjectJson) { + if ( + systemPromptOrGameProjectJson.includes('You are GDevelop AI Assistant') + ) { + effectiveSystemPrompt = systemPromptOrGameProjectJson; + } else { + effectiveSystemPrompt = buildSystemPrompt({ + gameProjectJson: systemPromptOrGameProjectJson, + projectSpecificExtensionsSummaryJson, + mode: (mode: any), + }); + } + } + + if (effectiveSystemPrompt) { + openAiMessages.push({ + role: 'system', + content: effectiveSystemPrompt, + }); + } + + for (const msg of outputMessages || []) { + // A persisted request's output is not shape-validated on load + // (`normalizePersistedMessages` passes a non-message entry through + // unchanged), so a hole or a scalar can be in the list. Reading `msg.role` + // unguarded threw out of every turn that replays history. + if (!msg || typeof msg !== 'object') continue; + if ( + msg.role === 'user' || + msg.type === 'user' || + (msg.type === 'message' && msg.role === 'user') + ) { + const text = + msg.text || + (typeof msg.content === 'string' ? msg.content : '') || + (Array.isArray(msg.content) + ? msg.content + .filter( + item => + item && + typeof item === 'object' && + (item.type === 'user_request' || item.text) + ) + .map(item => item.text) + .join('\n') + : ''); + openAiMessages.push({ + role: 'user', + content: text, + }); + } else if ( + msg.role === 'assistant' || + msg.type === 'assistant' || + (msg.type === 'message' && msg.role === 'assistant') + ) { + const content = msg.text || ''; + const toolCalls = []; + + // Extract function calls + if (Array.isArray(msg.functionCalls)) { + for (const fc of msg.functionCalls) { + if (!fc || typeof fc !== 'object') continue; + toolCalls.push({ + id: fc.id || `call_${Date.now()}`, + type: 'function', + function: { + name: fc.name, + arguments: + typeof fc.callArguments === 'string' + ? fc.callArguments + : JSON.stringify(fc.callArguments || {}), + }, + }); + } + } + + if (Array.isArray(msg.content)) { + for (const item of msg.content) { + if (!item || typeof item !== 'object') continue; + if (item.type === 'function_call') { + toolCalls.push({ + id: + item.call_id || item.callId || item.id || `call_${Date.now()}`, + type: 'function', + function: { + name: item.name, + arguments: + typeof item.arguments === 'string' + ? item.arguments + : JSON.stringify(item.arguments || {}), + }, + }); + } + } + } + + const assistantMsg: Object = { + role: 'assistant', + content: content || null, + }; + if (toolCalls.length > 0) { + // De-duplicate tool calls by ID + const seenCallIds = new Set(); + const uniqueToolCalls = []; + for (const tc of toolCalls) { + if (!seenCallIds.has(tc.id)) { + seenCallIds.add(tc.id); + uniqueToolCalls.push(tc); + } + } + assistantMsg.tool_calls = uniqueToolCalls; + } + openAiMessages.push(assistantMsg); + } else if (msg.type === 'function_call_output' || msg.role === 'tool') { + if (Array.isArray(msg.functionCallOutputs)) { + for (const fco of msg.functionCallOutputs) { + if (!fco || typeof fco !== 'object') continue; + openAiMessages.push({ + role: 'tool', + tool_call_id: fco.callId || fco.call_id || fco.id, + content: + typeof fco.output === 'string' + ? fco.output + : JSON.stringify(fco.output || {}), + }); + } + } else { + openAiMessages.push({ + role: 'tool', + tool_call_id: msg.callId || msg.call_id || msg.id, + content: + typeof msg.output === 'string' + ? msg.output + : JSON.stringify(msg.output || {}), + }); + } + } + } + + // Ensure consecutive tool outputs don't have duplicate tool_call_id + const seenToolOutputIds = new Set(); + return openAiMessages.filter(msg => { + if (msg.role === 'tool' && msg.tool_call_id) { + if (seenToolOutputIds.has(msg.tool_call_id)) { + return false; + } + seenToolOutputIds.add(msg.tool_call_id); + } + return true; + }); +}; + +/** + * Build the system prompt with project structure and guidelines. + * + * `role` and `spawnContextNote` are the studio's additions (see + * `AiGeneration/Studio/`). Both are optional: with neither, the prompt is exactly + * what the chat and the hosted-style agent turns have always used. + */ +export const buildSystemPrompt = ({ + gameProjectJson, + projectSpecificExtensionsSummaryJson, + mode, + role, + spawnContextNote, +}: {| + gameProjectJson?: string | null, + projectSpecificExtensionsSummaryJson?: string | null, + mode?: 'chat' | 'agent' | 'orchestrator', + role?: string | null, + spawnContextNote?: string | null, +|}): string => { + // An unrecognized role id must not throw here: `role` is read back from a + // persisted AiRequest (`existing.studioRoleId`), which is not validated on + // load, so a stale or corrupted id would kill the turn while its prompt is + // being built. Fall back to the shared prompt instead. + const rolePrompt = + role && isStudioRoleId(role) + ? getStudioRole((role: any)).systemPrompt + : null; + + let prompt = rolePrompt + ? `${rolePrompt} + +You are working inside GDevelop, the game engine this project is built with. The tools you can call are restricted to the ones your role is granted: if you need something you cannot do, report it instead of trying another route. + +` + : ''; + + prompt += `You are GDevelop AI Assistant, an expert game engine developer. You help users build games in GDevelop 5. + +Guidelines: +1. Always use available tools/functions to inspect or modify the project. +2. When creating objects, use \`create_or_replace_object\`. +3. When adding behaviors, use \`add_behavior\`. +4. When placing instances in scenes, use \`put_2d_instances\` or \`put_3d_instances\`. +5. When changing scene properties (e.g. background color), use \`change_scene_properties_layers_effects_groups\`. +6. To modify scene events: + Call add_scene_events. +7. If using run_script: + You MUST sequentially await every function call with \`await\` (e.g. \`await change_scene_properties_layers_effects_groups({ scene_name: '...', changed_properties: [{ property_name: 'backgroundColor', new_value: '#123456' }] });\`). Never call editor functions concurrently or without \`await\`. + +`; + + if (gameProjectJson) { + prompt += `\nCurrent Project Structure:\n${gameProjectJson}\n`; + } + if (projectSpecificExtensionsSummaryJson) { + prompt += `\nInstalled Project Extensions:\n${projectSpecificExtensionsSummaryJson}\n`; + } + if (spawnContextNote) { + prompt += `\n${spawnContextNote}\n`; + } + + return prompt; +}; + +/** + * Actionable hints for the failure modes of local servers (Ollama, llama.cpp, + * LM Studio) and BYOK providers, matched on the error payload/code. Local-first: + * the hints point at the local machine or the endpoint config, never at a + * GDevelop service. + */ +// The context-overflow pattern is shared by the error-hint table and the +// retry-with-a-tighter-budget path, so the two can never disagree. +const CONTEXT_OVERFLOW_PATTERN = /context length|context window|context size|maximum context|exceeds the (maximum )?context|prompt is too long|input is too long|too many tokens/i; + +/** + * Whether an error is the server refusing an over-window request. Used to + * decide a single retry with a smaller message budget on the same endpoint. + */ +// A local server that is still starting (or a transient network split) can +// refuse a connection and succeed moments later. One short retry turns a +// spurious failure into a successful turn. +const NETWORK_RETRY_DELAY_MS = 500; +const delay = (ms: number): Promise => + new Promise(resolve => setTimeout(resolve, ms)); + +// A provider can return a transient 429/5xx ("endpoint unavailable, reset after +// 6s") that succeeds seconds later. Without a retry the whole agent turn fails +// - which for a sub-agent wastes its work (feedback-loop Run 6: a developer died +// on a 503). Bounded and backed off, so a persistent outage still fails. +const PROVIDER_RETRY_DELAY_MS = 1500; +const MAX_PROVIDER_RETRIES = 2; + +// Overridable in tests so the retry/fallback path does not wait for real +// backoff. Production never changes it. +let providerRetryDelayMs = PROVIDER_RETRY_DELAY_MS; +export const _setProviderRetryDelayForTesting = (ms: number): void => { + providerRetryDelayMs = ms; +}; + +/** The HTTP status of a thrown provider error, or null. */ +export const getProviderErrorStatus = (error: any): number | null => { + const status = error && (error: any).providerStatus; + return typeof status === 'number' ? status : null; +}; + +/** + * Whether a provider error is worth retrying: rate limiting (429), request + * timeout (408) or a gateway/server-availability failure (502/503/504). A 4xx + * client error and a bare 500 are NOT retried: 500 is deliberately left + * untouched so a genuine provider error reads exactly as the server sent it + * (see the status-hint comment in `sendChatCompletion`). + */ +export const isRetryableProviderError = (error: any): boolean => { + const status = getProviderErrorStatus(error); + if (status === null) return false; + return status === 408 || status === 429 || (status >= 502 && status <= 504); +}; + +/** + * Run a turn, retrying a transient provider error (429/5xx) with backoff, and + * finally falling back to an optional secondary model before giving up. Aborted + * turns are never retried. + */ +const runTurnWithProviderRetry = async ( + sendTurn: (?string) => Promise, + abortSignal: { aborted: boolean }, + fallbackModel?: string | null +): Promise => { + let attempt = 0; + let triedFallback = false; + for (;;) { + try { + return await sendTurn(triedFallback ? fallbackModel : undefined); + } catch (error) { + if (abortSignal.aborted || !isRetryableProviderError(error)) { + throw error; + } + if (attempt < MAX_PROVIDER_RETRIES) { + attempt++; + const delayMs = providerRetryDelayMs * attempt; + console.warn( + `[CustomAIClient] Transient provider error (${getProviderErrorStatus( + error + )}); retrying in ${delayMs}ms (attempt ${attempt}/${MAX_PROVIDER_RETRIES}).` + ); + await delay(delayMs); + continue; + } + const canFallBack = + !triedFallback && + typeof fallbackModel === 'string' && + !!fallbackModel.trim(); + if (canFallBack) { + triedFallback = true; + console.warn( + `[CustomAIClient] Provider still failing (${getProviderErrorStatus( + error + )}); falling back to model "${fallbackModel}".` + ); + await delay(providerRetryDelayMs); + continue; + } + throw error; + } + } +}; + +/** + * Whether an error is a transport-level failure (no HTTP response): the server + * is unreachable, the connection was reset, DNS failed, etc. A provider error + * with a status (4xx/5xx) is NOT transport-level and must not be retried here. + */ +export const isNetworkLevelError = (error: any): boolean => { + if (!error || error.response) return false; + const code = error.code ? String(error.code) : ''; + const message = typeof error.message === 'string' ? error.message : ''; + return /ECONNREFUSED|ECONNRESET|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|network error|failed to fetch|load failed|socket hang up/i.test( + `${code} ${message}` + ); +}; + +export const isContextOverflowError = (error: any): boolean => { + const haystacks: Array = []; + const data = error && error.response && error.response.data; + if (data && data.error) { + haystacks.push( + typeof data.error === 'string' + ? data.error + : data.error.message || JSON.stringify(data.error) + ); + } + if (error && error.message) haystacks.push(String(error.message)); + return haystacks.some(haystack => CONTEXT_OVERFLOW_PATTERN.test(haystack)); +}; + +const LOCAL_SERVER_ERROR_HINTS = [ + { + // Context overflow, listed before the VRAM patterns: a local server + // reports it as a 400 whose text often contains "too large", which the + // VRAM pattern below would otherwise claim. The budget in this file trims + // history to fit, so reaching this means the request still exceeded the + // window — an unknown model family defaulting too high, a tool schema + // that alone fills a small window, or a single oversized message. Nothing + // here can retry the turn smaller, so the hint names what the user can do. + pattern: CONTEXT_OVERFLOW_PATTERN, + hint: + 'The request is larger than the model can accept at once. Start a new chat to send less history, or use a model with a larger context window.', + }, + { + pattern: /out of memory|vram|more (system )?memory|memory required|cuda|insufficient memory|too large/i, + hint: + 'The model likely does not fit in the available GPU/VRAM. Try a smaller quantization, reduce the context length, or close other GPU applications.', + }, + { + pattern: /model .*not found|no such model|unknown model|not loaded/i, + hint: + 'The model may not be loaded on the server. Check the model identifier in the AI preferences, or pull/load the model first.', + }, +]; + +const getErrorHint = (error: any): string => { + const haystacks: Array = []; + const data = error && error.response && error.response.data; + if (data && data.error) { + haystacks.push( + typeof data.error === 'string' + ? data.error + : data.error.message || JSON.stringify(data.error) + ); + } + if (error && error.code) haystacks.push(String(error.code)); + if (error && error.message) haystacks.push(String(error.message)); + + for (const { pattern, hint } of LOCAL_SERVER_ERROR_HINTS) { + if (haystacks.some(haystack => pattern.test(haystack))) return ` (${hint})`; + } + if ( + error && + (error.code === 'ECONNREFUSED' || + /econnrefused|network error/i.test(String(error.message || ''))) + ) { + return ' (The endpoint is not reachable: check the base URL and make sure the local AI server is running.)'; + } + return ''; +}; + +/** + * One-line provider telemetry for the dev console, from routing/proxy headers + * (OmniRoute `x-omniroute-*`; other OpenAI-compatible servers send none). + * Makes per-turn latency and token burn visible while debugging slow or + * thrashing agent runs, without touching the request itself. Null when the + * response carries no telemetry headers. + */ +export const parseProviderTelemetry = (headers: any): ?Object => { + if (!headers || typeof headers !== 'object') return null; + const get = (name: string): ?string => { + // Prefer .get when present: fetch Headers expose nothing by index, and + // AxiosHeaders.get is case-insensitive while index access is not. + const raw = + typeof headers.get === 'function' ? headers.get(name) : headers[name]; + return typeof raw === 'string' && raw ? raw : null; + }; + const toNumber = (value: ?string): ?number => { + if (value === null || value === undefined) return null; + const n = Number(value); + return Number.isFinite(n) ? n : null; + }; + const model = get('x-omniroute-model'); + const latencyMs = toNumber(get('x-omniroute-latency-ms')); + const tokensIn = toNumber(get('x-omniroute-tokens-in')); + const tokensOut = toNumber(get('x-omniroute-tokens-out')); + const cache = get('x-omniroute-cache'); + if ( + !model && + latencyMs === null && + tokensIn === null && + tokensOut === null && + !cache + ) { + return null; + } + const telemetry: Object = {}; + if (model) telemetry.model = model; + if (latencyMs !== null) telemetry.latencyMs = latencyMs; + if (tokensIn !== null) telemetry.tokensIn = tokensIn; + if (tokensOut !== null) telemetry.tokensOut = tokensOut; + if (cache) telemetry.cache = cache; + return telemetry; +}; + +export const formatProviderTelemetry = (headers: any): string | null => { + const telemetry = parseProviderTelemetry(headers); + if (!telemetry) return null; + const parts = []; + if (telemetry.model) parts.push(`model=${telemetry.model}`); + if (typeof telemetry.latencyMs === 'number') + parts.push(`latencyMs=${telemetry.latencyMs}`); + if ( + typeof telemetry.tokensIn === 'number' || + typeof telemetry.tokensOut === 'number' + ) + parts.push( + `tokens=${telemetry.tokensIn != null ? telemetry.tokensIn : '?'}in/${ + telemetry.tokensOut != null ? telemetry.tokensOut : '?' + }out` + ); + if (telemetry.cache) parts.push(`cache=${telemetry.cache}`); + return parts.length > 0 ? parts.join(' ') : null; +}; + +/** + * Stream a chat completion via SSE (fetch + ReadableStream) and return the + * same message shape as the non-streaming path. Skips malformed chunks; a + * connection drop mid-stream surfaces as a stream error. Aborts on the + * caller signal, on the configured timeout, or on server-side cancellation. + */ +const streamChatCompletion = async ({ + endpointUrl, + headers, + payload, + signal, + timeoutMs, + onStreamDelta, + onProviderTelemetry, +}: {| + endpointUrl: string, + headers: { [string]: string }, + payload: Object, + signal?: ?AbortSignal, + timeoutMs: number, + onStreamDelta?: (partialContent: string) => void, + onProviderTelemetry?: (telemetry: Object) => void, +|}): Promise => { + const combinedController = new AbortController(); + const abortFromCaller = () => combinedController.abort(); + if (signal) { + if (signal.aborted) combinedController.abort(); + else signal.addEventListener('abort', abortFromCaller); + } + // The deadline bounds the connect phase (the request must start streaming + // within timeoutMs) and, once the body is being read, becomes a per-chunk + // idle watchdog: a slow local model must never be killed mid-generation + // while it is still producing output, but a server that goes silent (model + // load stall, VRAM swap, silently dropped TCP connection) must not hang the + // chat forever. + let watchdogFired = false; + let idleWatchdogArmed = false; + let timeoutId = setTimeout(() => { + watchdogFired = true; + combinedController.abort(); + }, timeoutMs); + const armIdleTimeout = () => { + idleWatchdogArmed = true; + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + watchdogFired = true; + combinedController.abort(); + }, timeoutMs); + }; + + try { + const response = await fetch(endpointUrl, { + method: 'POST', + headers, + body: JSON.stringify({ ...payload, stream: true }), + signal: combinedController.signal, + }); + + if (!response.ok) { + let errorMessage = `HTTP error ${response.status}`; + try { + const data = await response.json(); + errorMessage = + (data && data.error && (data.error.message || data.error)) || + errorMessage; + } catch (ignored) { + // Non-JSON error body: keep the generic message. + } + const error = new Error( + `AI Provider Error (${response.status}): ${errorMessage}` + ); + // $FlowFixMe[prop-missing] attach status for hint resolution. + error.response = { + status: response.status, + data: { error: { message: errorMessage } }, + }; + throw error; + } + // Thrash forensics, same one-line format as the non-streaming path: a + // session with streaming on emitted no per-turn observability at all. + // Absent (or CORS-hidden) headers stay quiet via the null return. + const streamTelemetry = formatProviderTelemetry(response.headers); + if (streamTelemetry) { + console.debug(`[BYOK] ${endpointUrl} ${streamTelemetry}`); + } + if (onProviderTelemetry) { + const parsed = parseProviderTelemetry(response.headers); + if (parsed) onProviderTelemetry(parsed); + } + // Some proxies and a few local servers ignore `stream: true` and answer + // with an ordinary JSON completion. Reading that as SSE yields no `data:` + // lines, so the stream looks empty and the turn is retried without + // streaming — a second full wait for an answer that already arrived. + // Detect a non-SSE content type and treat the body as the reply. + const contentType = + (response.headers && response.headers.get + ? response.headers.get('content-type') + : '') || ''; + if (contentType && !/text\/event-stream/i.test(contentType)) { + let parsed; + try { + parsed = JSON.parse(await response.text()); + } catch (ignored) { + throw new Error( + `The endpoint answered with '${contentType}' instead of an event stream, and the body was not JSON.` + ); + } + const message = + parsed && + parsed.choices && + parsed.choices[0] && + parsed.choices[0].message; + if (!message) { + throw new Error( + `The endpoint answered with '${contentType}' instead of an event stream, and the body carried no completion.` + ); + } + return message; + } + if (!response.body) { + throw new Error('The endpoint returned an empty stream.'); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let content = ''; + let reasoning = ''; + // The server signals the end of an SSE completion with `data: [DONE]`. + // Many OpenAI-compatible servers and proxies keep the connection open + // afterwards (chunked keep-alive), so waiting for the socket to close + // means a finished answer sits idle until the watchdog aborts it — the + // user's answer appears, but only after the full idle timeout. + let sawDone = false; + // The reason the server streamed, when it sent an error chunk instead of + // a completion. + let streamErrorMessage = null; + const toolCalls: { [index: number]: Object } = {}; + let finishReason = null; + + // Local proxies (and some Ollama builds) omit `index` on streamed tool_call + // deltas. Naively defaulting to 0 merges every call into the first one. + const resolveStreamToolCallIndex = (toolCall: Object): number => { + if (typeof toolCall.index === 'number' && toolCall.index >= 0) { + return toolCall.index; + } + const keys = Object.keys(toolCalls); + const lastIndex = keys.length + ? Math.max(...keys.map(key => Number(key))) + : -1; + if (toolCall.id) { + for (const key of keys) { + if (toolCalls[key].id === toolCall.id) return Number(key); + } + // New id without index: start the next slot, do not merge into 0. + return lastIndex + 1; + } + // No index and no id: continue the open call (split name/args fragments). + // A second distinct call must carry an id to get its own slot. + return lastIndex >= 0 ? lastIndex : 0; + }; + + const processSseLine = (rawLine: string) => { + const trimmed = rawLine.trim(); + if (!trimmed.startsWith('data:')) return; + const data = trimmed.slice(5).trim(); + if (!data) return; + if (data === '[DONE]') { + sawDone = true; + return; + } + let chunk; + try { + chunk = JSON.parse(data); + } catch (ignored) { + return; // malformed chunk: skip, the stream may recover. + } + // A streamed error chunk (`{"error": {...}}`) carries the server's own + // reason (model loading, OOM). Ignoring it left only the generic + // "connection may have been dropped" message for a failure the server + // had already explained. + if (chunk && chunk.error) { + const rawError = chunk.error; + streamErrorMessage = + typeof rawError === 'string' + ? rawError + : rawError.message || JSON.stringify(rawError); + return; + } + const choice = chunk && chunk.choices && chunk.choices[0]; + if (!choice) return; + const delta = choice.delta || {}; + if (typeof delta.content === 'string') content += delta.content; + // Reasoning models (DeepSeek-R1, qwq, Ollama reasoning builds) stream + // their chain of thought on `reasoning_content`, the same field the + // non-streaming path reads in parseAssistantMessage. Ollama-style + // endpoints (and the OmniRoute proxy) use `reasoning` instead. + // Dropping either here meant the streamed answer lost the thinking + // the non-streamed one kept. + if (typeof delta.reasoning_content === 'string') { + reasoning += delta.reasoning_content; + } else if (typeof delta.reasoning === 'string') { + reasoning += delta.reasoning; + } + if (Array.isArray(delta.tool_calls)) { + for (const toolCall of delta.tool_calls) { + const index = resolveStreamToolCallIndex(toolCall); + const existing = toolCalls[index]; + const fragmentName = toolCall.function && toolCall.function.name; + const fragmentArgs = toolCall.function && toolCall.function.arguments; + if (!existing) { + // Arguments are normally a string, but a proxy may send the whole + // object on one chunk: stringify it so a later string fragment + // cannot concatenate as '[object Object]'. Names and ids are + // coerced to strings for the same reason. + toolCalls[index] = { + id: typeof toolCall.id === 'string' ? toolCall.id : undefined, + type: toolCall.type || 'function', + function: { + name: typeof fragmentName === 'string' ? fragmentName : '', + arguments: + typeof fragmentArgs === 'string' + ? fragmentArgs + : fragmentArgs && typeof fragmentArgs === 'object' + ? JSON.stringify(fragmentArgs) + : '', + }, + }; + } else { + if (typeof toolCall.id === 'string') existing.id = toolCall.id; + if (typeof fragmentName === 'string') { + existing.function.name = + (typeof existing.function.name === 'string' + ? existing.function.name + : '') + fragmentName; + } + if (typeof fragmentArgs === 'string') { + existing.function.arguments = + (typeof existing.function.arguments === 'string' + ? existing.function.arguments + : '') + fragmentArgs; + } + } + } + } + if (choice.finish_reason) finishReason = choice.finish_reason; + // Progress = answer text, or the reasoning when there is no answer yet: + // a reasoning model (DeepSeek-R1, and Ollama's deepseek stream) emits + // many reasoning chunks before the first content chunk, and reporting + // '' meanwhile made the chat's cold-start hint claim it was still + // waiting for a first token while bytes were arriving. Live finding, + // cycle 191. + if (onStreamDelta) onStreamDelta(content || reasoning); + }; + + // The response is live: from here the deadline is measured from the last + // received chunk, not from the request start. + armIdleTimeout(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + armIdleTimeout(); + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; // keep the trailing partial line. + for (const line of lines) processSseLine(line); + // Stop as soon as the server says the completion is over, rather than + // waiting for a socket close the server may never send. + if (sawDone) break; + } + // Connection close leaves a partial line in `buffer` and undecoded + // multi-byte state in `decoder` — process both so a final data: event + // without a trailing newline is not discarded. + buffer += decoder.decode(); + if (buffer) processSseLine(buffer); + + const toolCallsArray = Object.keys(toolCalls) + .map(index => toolCalls[index]) + .filter(toolCall => toolCall && toolCall.function.name); + + if (streamErrorMessage) { + // The server explained this failure itself, so it is deterministic: + // retrying non-streaming would wait again for the same answer. + // $FlowFixMe[prop-missing] flags read by sendChatCompletion below. + const streamedError: any = new Error( + `AI Provider Error (streamed): ${streamErrorMessage}` + ); + streamedError.isServerReportedError = true; + throw streamedError; + } + if (finishReason === 'length' && !content) { + // A reasoner that ran out of output budget has already spent it on + // its thinking: blaming a dropped connection would send the user to + // debug their network when the fix is to raise the output limit. + // $FlowFixMe[prop-missing] flag read by sendChatCompletion below. + const budgetError: any = new Error( + 'The model used its whole output budget before writing an answer. Raise the max output tokens in the AI preferences, or ask for a shorter answer.' + ); + budgetError.isOutputBudgetExhausted = true; + throw budgetError; + } + if ( + !content && + toolCallsArray.length === 0 && + finishReason !== 'stop' && + finishReason !== 'tool_calls' + ) { + throw new Error( + 'The AI provider stream ended without any content (connection may have been dropped mid-stream).' + ); + } + + // Typed as Object (like `payload` below): the optional fields are only + // attached when present, so an object literal's inferred shape is wrong. + const message: Object = { role: 'assistant', content }; + if (reasoning) message.reasoning_content = reasoning; + if (toolCallsArray.length > 0) message.tool_calls = toolCallsArray; + return message; + } catch (error) { + if (signal && signal.aborted) { + throw new Error('AI request was aborted.'); + } + if (watchdogFired || (error && error.name === 'AbortError')) { + // Flag stream timeouts so sendChatCompletion does not immediately start + // a second non-streaming request that would wait another full timeoutMs. + // $FlowFixMe[prop-missing] optional flag read by sendChatCompletion. + const timeoutError: any = new Error( + idleWatchdogArmed + ? // The connection was open but the server stopped sending (model + // load stall, VRAM swap, silently dropped TCP connection): report + // the stall, which is the actionable local-server symptom, rather + // than blaming the endpoint URL. + `The model stream stalled: no data received for ${timeoutMs} ms (streaming). The local model may still be loading, or the server stopped responding — increase the timeout in the AI preferences, or retry with a smaller model.` + : `The request timed out or was aborted after ${timeoutMs} ms (streaming). Increase the timeout in the AI preferences for slow local models.` + ); + timeoutError.isStreamTimeout = true; + throw timeoutError; + } + if ( + error instanceof TypeError && + /failed to fetch|networkerror|load failed/i.test(error.message || '') + ) { + throw new Error( + `The endpoint is not reachable: check the base URL and make sure the local AI server is running. Original error: ${ + error.message + }` + ); + } + // Streaming is not universally supported: let the caller retry without it. + // $FlowFixMe[prop-missing] + error.isStreamUnsupported = !!(error.response && error.response.status); + throw error; + } finally { + clearTimeout(timeoutId); + if (signal) signal.removeEventListener('abort', abortFromCaller); + } +}; + +/** + * Send a chat completion request to the OpenAI-compatible endpoint. + */ +export const sendChatCompletion = async ({ + messages, + tools, + config, + signal, + onStreamDelta, + onProviderTelemetry, +}: {| + messages: Array, + tools?: ?Array, + config?: ?CustomAIConfig, + signal?: ?AbortSignal, + onStreamDelta?: (partialContent: string) => void, + onProviderTelemetry?: (telemetry: Object) => void, +|}): Promise => { + const currentConfig = config || getCustomEndpointConfig(); + const baseUrl = normalizeBaseUrl(currentConfig.baseUrl); + const endpointUrl = getEndpointUrl(baseUrl, '/chat/completions'); + + // Content-Type only by default: the OpenRouter attribution headers + // (HTTP-Referer/X-Title) are optional and, for a local OpenAI-compatible + // server, actively harmful — a browser preflight rejects `x-title` because + // Ollama's Access-Control-Allow-Headers does not list it, so the whole + // request is blocked before it reaches the model (live finding, cycle 189). + const headers: { [string]: string } = { + 'Content-Type': 'application/json', + }; + // Apply only safe custom headers; never let them replace Content-Type or + // inject CR/LF, even when the config object skipped sanitizeCustomAIConfig. + const customHeaders = currentConfig.customHeaders || {}; + for (const headerName of Object.keys(customHeaders)) { + const headerValue = customHeaders[headerName]; + if (isSafeRequestHeader(headerName, headerValue)) { + headers[headerName] = headerValue; + } + } + + if (currentConfig.apiKey && currentConfig.apiKey.trim()) { + headers['Authorization'] = `Bearer ${currentConfig.apiKey.trim()}`; + } + + const timeoutMs = + typeof currentConfig.timeoutMs === 'number' && currentConfig.timeoutMs > 0 + ? Math.min(currentConfig.timeoutMs, MAX_TIMEOUT_MS) + : 120000; + + const payload: Object = { + model: currentConfig.model || 'qwen2.5-coder', + messages, + // Clamped here, not only in sanitizeCustomAIConfig: this function accepts a + // raw `config` (`config || getCustomEndpointConfig()`), and testConnection + // merges a caller-supplied partial config over the stored one, so an + // out-of-range value reaches the wire unclamped. A non-finite value also + // serializes to `"temperature": null`, which strict OpenAI-compatible + // servers reject rather than ignore. + temperature: + typeof currentConfig.temperature === 'number' && + Number.isFinite(currentConfig.temperature) + ? Math.max(0, Math.min(1, currentConfig.temperature)) + : 0.7, + }; + + if (tools && tools.length > 0) { + payload.tools = tools; + } + if (currentConfig.maxTokens) { + payload.max_tokens = currentConfig.maxTokens; + } + + if (currentConfig.streaming && typeof fetch !== 'undefined') { + try { + return await streamChatCompletion({ + endpointUrl, + headers, + payload, + signal, + timeoutMs, + onStreamDelta, + onProviderTelemetry, + }); + } catch (streamError) { + // A user abort must not trigger a retry: the caller signal is already + // aborted and the non-streaming request would immediately fail too. + if (signal && signal.aborted) throw streamError; + // Stream timeouts already waited timeoutMs — a non-streaming retry would + // double the stall. Surface the timeout immediately with its guidance. + // $FlowFixMe[prop-missing] optional flag set by streamChatCompletion. + if (streamError && streamError.isStreamTimeout) throw streamError; + // Deterministic: the same budget applies to a non-streaming retry, which + // would only double the wait before failing identically. + // $FlowFixMe[prop-missing] flag set by streamChatCompletion. + if (streamError && streamError.isOutputBudgetExhausted) throw streamError; + // Deterministic: the server reported the failure in the stream, so the + // non-streaming retry would only re-ask the same failing model. + // $FlowFixMe[prop-missing] flag set by streamChatCompletion. + if (streamError && streamError.isServerReportedError) throw streamError; + // Some endpoints reject streaming or drop mid-stream; a single + // non-streaming retry keeps the turn alive at worst-case latency. + console.warn( + 'Streaming failed, retrying without streaming:', + streamError.message + ); + } + } + + try { + // axios >=0.22 accepts the AbortSignal directly (and releases the + // listener when the request settles) — no CancelToken bridge needed. + const response = await axios.post(endpointUrl, payload, { + headers, + signal: signal || undefined, + timeout: timeoutMs, + }); + + // Thrash forensics: per-turn latency/tokens from routing headers, if any. + const providerTelemetry = formatProviderTelemetry(response.headers); + if (providerTelemetry) { + console.debug(`[BYOK] ${endpointUrl} ${providerTelemetry}`); + } + if (onProviderTelemetry) { + const parsed = parseProviderTelemetry(response.headers); + if (parsed) onProviderTelemetry(parsed); + } + + if ( + response.data && + response.data.choices && + response.data.choices[0] && + response.data.choices[0].message + ) { + return response.data.choices[0].message; + } + + // Some servers report a model failure with HTTP 200 and an error body + // rather than a 4xx/5xx. Returning it as a message made the turn look + // successful: parseAssistantMessage found no `content`, so an EMPTY + // assistant reply was recorded with no error and no retry offered. + if (response.data && response.data.error) { + const rawError = response.data.error; + const message = + typeof rawError === 'string' + ? rawError + : rawError.message || JSON.stringify(rawError); + const error = new Error( + `AI Provider Error (200 with an error body): ${message}` + ); + // $FlowFixMe[prop-missing] attach status so hints resolve as for a 4xx. + error.response = { status: 200, data: response.data }; + throw error; + } + + return response.data; + } catch (error) { + if (axios.isCancel(error) || (signal && signal.aborted)) { + throw new Error('AI request was aborted.'); + } + // `error` may be undefined (a rejected promise with no reason) or a + // non-object: reading `.response` on it threw a misleading TypeError out + // of the handler before any normalization ran. + if (error && error.response) { + const status = error.response.status; + const data = error.response.data; + const errorMsg = + (data && data.error && (data.error.message || data.error)) || + error.message || + `HTTP error ${status}`; + const hasApiKey = !!(currentConfig.apiKey && currentConfig.apiKey.trim()); + const authHint = + status === 401 && !hasApiKey + ? ' (No API key is configured for this endpoint — set one in the AI preferences if the provider requires it.)' + : ''; + // Status-specific guidance for the failure modes the objective names + // (429/502). 500 is deliberately left untouched so a genuinely + // unmatched provider error still reads exactly as the server sent it. + const statusHint = + status === 429 + ? ' (The provider is rate limiting requests: wait a moment and retry, or reduce concurrent requests.)' + : status === 502 || status === 503 + ? ' (The provider is temporarily unavailable: retry in a moment; if it persists, check the provider status or your base URL.)' + : ''; + const providerError = new Error( + `AI Provider Error (${status}): ${errorMsg}${authHint}${statusHint}${getErrorHint( + error + )}` + ); + // Keep the HTTP status on the error so a caller can retry a transient + // provider failure (429/5xx) instead of failing the whole agent turn. + (providerError: any).providerStatus = status; + throw providerError; + } + // No response at all (server down, network split, DNS): add the local + // reachability/model hints when they match. The thrown value may be + // undefined (a rejected promise with no reason) or a non-Error, so it + // must be normalized before `.message`-style reads: the raw throw leaked a + // misleading "Cannot read properties of undefined" TypeError out of this + // handler to every caller (testConnection surfaced it as the connection + // failure reason). + const networkHint = getErrorHint(error); + if (!(error instanceof Error)) { + throw new Error( + `AI request failed.${ + error === undefined || error === null ? '' : ` ${String(error)}` + }${networkHint}` + ); + } + throw networkHint + ? new Error(`${error.message || 'AI request failed.'}${networkHint}`) + : error; + } +}; + +// Names must match GDEVELOP_OPENAI_TOOLS exactly: three entries here used to +// be `describe_*`, which is not what the registry declares (`read_events_source`, +// `inspect_scene_properties_layers_effects`, `inspect_variables`), so the +// embedded-JSON fallback was silently disabled for those tools — a model that +// emitted its call as a JSON block got no tool executed at all. +// Every name below is an inspection/read tool; none mutates the project. +/** + * The read-only subset of the tool schema, as a constant. + * + * Used as the fail-closed toolset when a sub-agent's role id cannot be + * resolved (a stale or corrupted persisted `studioRoleId`): a read-only agent + * must never be handed the mutating tools just because its role is unknown, + * and the turn must not throw mid-flight either. Computed once so no per-turn + * allocation is added to the hot path. + */ +const READ_ONLY_OPENAI_TOOLS = GDEVELOP_OPENAI_TOOLS.filter( + tool => + !!tool && + !!tool.function && + READ_ONLY_TOOL_NAMES.includes(tool.function.name) +); + +/** + * The tool subset a request may be offered, from its studio role and mode: + * a sub-agent uses its role's subset, an orchestrator-mode top-level request + * uses the manager's, a chat/agent request keeps every tool, and a request + * whose role id is unknown FAILS CLOSED to the read-only subset. + */ +const getToolsForStudioRequest = ( + studioRoleId: string | null | void, + mode: string | null | void +): Array => { + const { roleId, invalid } = resolveStudioRoleId(studioRoleId, mode); + if (invalid) return READ_ONLY_OPENAI_TOOLS; + if (roleId) return getToolsForRole(roleId, GDEVELOP_OPENAI_TOOLS); + return GDEVELOP_OPENAI_TOOLS; +}; + +/** + * Registry tools the backend resolves and this client cannot: their local + * launchFunction unconditionally fails, so offering them on a local turn + * burns the turn and its tokens for nothing. Filtered from every local send + * (and the budget computed for it); the schema keeps them for hosted turns + * and argument validation. + */ +export const BACKEND_ONLY_TOOL_NAMES: Array = [ + 'get_game_starter_summary', +]; + +export const withoutBackendOnlyTools = (tools: Array): Array => + tools.filter( + tool => + !!tool && + !!tool.function && + !BACKEND_ONLY_TOOL_NAMES.includes(tool.function.name) + ); + +export const SIDE_EFFECT_FREE_TOOLS = new Set([ + 'describe_instances', + 'read_events_source', + 'inspect_scene_properties_layers_effects', + 'inspect_variables', + 'inspect_object_properties_effects', + 'inspect_behavior_properties', + 'inspect_extension', + 'inspect_project_properties_resources', + 'read_game_project_json', + 'read_full_docs', + 'search_docs', + 'get_game_starter_summary', +]); + +/** + * Parse an OpenAI assistant message response into GDevelop's internal format. + */ +/** + * Validate parsed tool-call arguments against the tool's declared JSON Schema + * (subset: required properties, primitive types, enum values). Unknown tools + * pass — the schema registry is the source of truth only for known tools. + */ +export const validateToolCallArguments = ( + toolName: string, + parsedArgs: Object, + tools?: Array = GDEVELOP_OPENAI_TOOLS +): {| valid: boolean, errors: Array |} => { + const errors: Array = []; + const tool = tools.find( + candidate => + candidate && candidate.function && candidate.function.name === toolName + ); + const schema = tool && tool.function ? tool.function.parameters : null; + if ( + !schema || + typeof schema !== 'object' || + !schema.properties || + typeof parsedArgs !== 'object' || + parsedArgs === null || + Array.isArray(parsedArgs) + ) { + return { valid: true, errors }; + } + + const required: Array = Array.isArray(schema.required) + ? schema.required + : []; + for (const requiredName of required) { + if (!(requiredName in parsedArgs)) { + errors.push(`Missing required argument '${requiredName}'.`); + } + } + + const TYPE_CHECKS = { + string: value => typeof value === 'string', + number: value => typeof value === 'number' && !Number.isNaN(value), + boolean: value => typeof value === 'boolean', + object: value => + typeof value === 'object' && value !== null && !Array.isArray(value), + array: value => Array.isArray(value), + }; + + for (const argName of Object.keys(parsedArgs)) { + const propertySchema = schema.properties[argName]; + if (!propertySchema) { + errors.push(`Unknown argument '${argName}' (not in the tool schema).`); + continue; + } + const expectedType = propertySchema.type; + const check = expectedType && TYPE_CHECKS[expectedType]; + if (check && !check(parsedArgs[argName])) { + errors.push(`Argument '${argName}' should be of type '${expectedType}'.`); + continue; + } + if ( + check && + expectedType === 'string' && + Array.isArray(propertySchema.enum) && + !propertySchema.enum.includes(parsedArgs[argName]) + ) { + errors.push( + `Argument '${argName}' must be one of: ${propertySchema.enum + .map(value => `'${value}'`) + .join(', ')}.` + ); + } + } + + return { valid: errors.length === 0, errors }; +}; + +export const parseAssistantMessage = ( + openAiMessageOrChoiceOrResponse: Object, + messageId?: string +): AiRequestMessage => { + const openAiMessage = + openAiMessageOrChoiceOrResponse.message || + (openAiMessageOrChoiceOrResponse.choices && + openAiMessageOrChoiceOrResponse.choices[0] && + openAiMessageOrChoiceOrResponse.choices[0].message) || + openAiMessageOrChoiceOrResponse; + + // `content` is network-supplied: a proxy may send an object or an array + // of blocks (both truthy), and the old `|| ''` passed it into + // extractThinkingAndContent, whose `.match` then threw out of the turn. + // Only a string is parsed; anything else reads as empty. + const rawContent = + typeof openAiMessage.content === 'string' ? openAiMessage.content : ''; + // OpenAI-style endpoints use `reasoning_content`; Ollama-style endpoints + // (and the OmniRoute proxy) use `reasoning`. Accept both. Either is + // network-supplied, so only a string is accepted: a non-string would land + // in `summary.text`, which the chat renders as text. + const reasoningCandidate = + openAiMessage.reasoning_content || openAiMessage.reasoning; + const reasoningContent = + typeof reasoningCandidate === 'string' ? reasoningCandidate : null; + + let { thinking, cleanContent } = extractThinkingAndContent(rawContent); + if (!thinking && reasoningContent) { + thinking = reasoningContent; + } + + const contentArray: Array = []; + // Reasoning first: the chat renders content in order, and the thinking + // precedes the answer it produced. Without this the extracted `thinking` was + // only a top-level field that no component read, so a reasoning model's + // chain of thought was computed and then thrown away — ChatMessages already + // had a `type === 'reasoning'` branch, but nothing ever produced such an + // entry to reach it. + if (thinking) { + contentArray.push({ + type: 'reasoning', + status: 'completed', + summary: { text: thinking, type: 'summary_text' }, + }); + } + if (cleanContent) { + contentArray.push({ + // 'output_text' is the type every consumer expects: ChatMessages renders + // only that (and 'reasoning'), RenderItem's messageContent union admits + // only those two, and FinalizeSubAgents reads it as the server's shape. + // The local parser emitted 'text', which no branch handles, so the + // assistant's own answer rendered as nothing. + type: 'output_text', + status: 'completed', + text: cleanContent, + annotations: [], + }); + } + + const functionCalls: Array = []; + + // Standard tool_calls + if (Array.isArray(openAiMessage.tool_calls)) { + for (const toolCall of openAiMessage.tool_calls) { + if (toolCall.function) { + const functionName = toolCall.function.name; + const functionArgs = toolCall.function.arguments; + let parsedArgs = {}; + let argsValid = false; + try { + parsedArgs = + typeof functionArgs === 'string' + ? JSON.parse(functionArgs) + : functionArgs || {}; + // Arguments must be a JSON object — arrays or scalars would break + // tool implementations that read named properties. + argsValid = + !!parsedArgs && + typeof parsedArgs === 'object' && + !Array.isArray(parsedArgs); + } catch (e) { + console.warn('Error parsing function call arguments JSON:', e); + } + if (!argsValid) { + parsedArgs = {}; + console.warn( + `Tool call '${String( + functionName + )}' had invalid arguments; executing with empty arguments.` + ); + } else { + // run_script's editor implementation reads `js_code` (with an + // optional `title`); older prompts taught `script`. Accept the alias + // so either name validates and executes the same code. + if ( + String(functionName) === 'run_script' && + parsedArgs && + typeof parsedArgs === 'object' && + typeof parsedArgs.js_code !== 'string' && + typeof parsedArgs.script === 'string' + ) { + parsedArgs.js_code = parsedArgs.script; + } + const validation = validateToolCallArguments( + String(functionName), + parsedArgs + ); + if (!validation.valid) { + parsedArgs = {}; + console.warn( + `Tool call '${String( + functionName + )}' arguments failed schema validation: ${validation.errors.join( + ' ' + )} Executing with empty arguments.` + ); + } + } + const callId = + toolCall.id || + `call_${Date.now()}_${Math.random() + .toString(36) + .substr(2, 5)}`; + contentArray.push({ + type: 'function_call', + status: 'completed', + call_id: callId, + name: functionName, + // Round-trip the sanitized arguments so the tool executes exactly + // what was validated, not the malformed original. + arguments: argsValid + ? JSON.stringify(parsedArgs) + : typeof functionArgs === 'string' && functionArgs + ? functionArgs + : '{}', + }); + functionCalls.push({ + id: callId, + name: functionName, + callArguments: parsedArgs, + }); + } + } + } + + // Fallback: If no tool_calls but content contains markdown JSON tool call blocks (restricted to side-effect-free tools) + if ( + (!openAiMessage.tool_calls || openAiMessage.tool_calls.length === 0) && + cleanContent + ) { + const jsonBlockRegex = /```(?:json)?\s*(\{\s*"(?:function|name|tool)":[\s\S]*?\})\s*```/gi; + let match; + while ((match = jsonBlockRegex.exec(cleanContent)) !== null) { + try { + const parsed = JSON.parse(match[1]); + const name = parsed.function || parsed.name || parsed.tool; + const args = parsed.arguments || parsed.args || parsed.parameters || {}; + if ( + name && + typeof name === 'string' && + SIDE_EFFECT_FREE_TOOLS.has(name) + ) { + const callId = `call_parsed_${Date.now()}_${Math.random() + .toString(36) + .substr(2, 5)}`; + // Same contract as the tool_calls path (cycle-10/11 hardening): + // arguments must parse to a non-array object, validated against + // the tool schema before execution. + let parsedArgsObj; + try { + parsedArgsObj = typeof args === 'string' ? JSON.parse(args) : args; + } catch (argsParseErr) { + parsedArgsObj = null; + } + const argsValid = + !!parsedArgsObj && + typeof parsedArgsObj === 'object' && + !Array.isArray(parsedArgsObj); + const validation = validateToolCallArguments( + String(name), + argsValid ? parsedArgsObj : {} + ); + if (!argsValid || !validation.valid) { + console.warn( + `Parsed tool call '${String( + name + )}' had invalid arguments; skipping it.` + ); + continue; + } + contentArray.push({ + type: 'function_call', + status: 'completed', + call_id: callId, + name, + arguments: JSON.stringify(parsedArgsObj), + }); + functionCalls.push({ + id: callId, + name, + callArguments: parsedArgsObj, + }); + } + } catch (e) { + // Not a tool call JSON block, ignore + } + } + } + + return { + type: 'message', + status: 'completed', + role: 'assistant', + text: cleanContent, + thinking, + functionCalls, + content: contentArray, + messageId: messageId || `msg-asst-${Date.now()}`, + }; +}; + +/** + * Client-side Create AI Request implementation. + */ +export const customCreateAiRequest = async ({ + userRequest, + gameProjectJson, + projectSpecificExtensionsSummaryJson, + mode, + aiConfiguration, + gameId, +}: {| + userRequest: string, + gameProjectJson: string | null, + projectSpecificExtensionsSummaryJson: string | null, + mode?: 'chat' | 'agent' | 'orchestrator', + aiConfiguration?: any, + gameId?: string | null, +|}): Promise => { + const reqId = `local-ai-${Date.now()}-${Math.random() + .toString(36) + .substr(2, 7)}`; + const userMsgId = `msg-user-${Date.now()}`; + const assistantMsgId = `msg-asst-${Date.now()}`; + + const userMessage: AiRequestUserMessage = { + type: 'message', + status: 'completed', + role: 'user', + content: [ + { + type: 'user_request', + status: 'completed', + text: userRequest, + }, + ], + messageId: userMsgId, + }; + + // Stop clicked before this create registered: cancel without a network call. + if (createAbortRequested) { + createAbortRequested = false; + const cancelledNow = new Date().toISOString(); + const cancelledEarly: AiRequest = { + id: reqId, + createdAt: cancelledNow, + updatedAt: cancelledNow, + userId: LOCAL_BYOK_USER_ID, + gameId: gameId || null, + gameProjectJson: gameProjectJson || null, + status: 'suspended', + mode: mode || 'orchestrator', + aiConfiguration: aiConfiguration || { presetId: 'default' }, + toolsVersion: 'v14', + toolOptions: null, + error: null, + output: [userMessage], + lastUserMessagePriceInCredits: 0, + totalPriceInCredits: 0, + }; + localAiRequestsCache[reqId] = cancelledEarly; + saveLocalAiRequests(); + return cancelledEarly; + } + + const output: Array = [userMessage]; + + const systemPrompt = buildSystemPrompt({ + gameProjectJson, + projectSpecificExtensionsSummaryJson, + mode, + role: resolveStudioRoleId(null, mode).roleId, + }); + + const openAiMessages = transformGDevelopMessagesToOpenAi( + output, + systemPrompt + ); + + // Same budget as later turns: a create with a large embedded project + // structure must fit a small local model before the first request is sent. + // Backend-resolved tools are withheld from the offer and the budget alike. + const createConfig = getEffectiveConfigForRequest(reqId); + const createTools = withoutBackendOnlyTools( + getToolsForStudioRequest(null, mode) + ); + const createMessageBudget = getMessageBudget(createConfig, createTools); + let budgetedMessages = trimMessagesToBudget( + openAiMessages, + createMessageBudget + ); + noteSystemCompactedIfChanged(reqId, openAiMessages, budgetedMessages); + if (budgetedMessages.length < openAiMessages.length) { + const trimmedCount = openAiMessages.length - budgetedMessages.length; + localAiRequestTrimCounts[reqId] = + (localAiRequestTrimCounts[reqId] || 0) + trimmedCount; + console.warn( + `[CustomAIClient] Trimmed ${trimmedCount} old message(s) to fit the model context budget.` + ); + } + + const abortController = registerTurnAbortController(reqId); + pendingCreateAiRequestIds[reqId] = true; + const sendTurn = (modelOverride?: string) => + sendChatCompletion({ + messages: budgetedMessages, + tools: createTools, + config: modelOverride + ? { ...createConfig, model: modelOverride } + : createConfig, + signal: abortController.signal, + // The first turn is the one most likely to hit a model still loading, so + // it must publish partial content like addMessage does: the chat's + // cold-start hint reads this registry to tell "waiting for the first + // token" apart from "already streaming". + onStreamDelta: partialContent => { + localAiRequestPartialContent[reqId] = partialContent; + }, + // Same accounting as the other registries: inspectable per-request + // provider telemetry for the chat it belongs to. + onProviderTelemetry: telemetry => { + localAiRequestProviderTelemetry[reqId] = { + ...telemetry, + at: new Date().toISOString(), + }; + }, + }); + let assistantResponse; + try { + try { + assistantResponse = await runTurnWithProviderRetry( + sendTurn, + abortController.signal, + createConfig.fallbackModel + ); + } catch (error) { + // The first turn embeds the project structure, so it is the most likely + // to overflow: retry once at a halved budget before failing the create. + if (!abortController.signal.aborted && isContextOverflowError(error)) { + budgetedMessages = trimMessagesToBudget( + openAiMessages, + Math.max(256, Math.floor(createMessageBudget / 2)) + ); + console.warn( + '[CustomAIClient] The first request exceeded the context window; retrying once with a smaller prompt.' + ); + assistantResponse = await sendTurn(); + } else if ( + !abortController.signal.aborted && + isNetworkLevelError(error) + ) { + console.warn( + '[CustomAIClient] The endpoint is unreachable; retrying once shortly.' + ); + await delay(NETWORK_RETRY_DELAY_MS); + assistantResponse = await sendTurn(); + } else { + throw error; + } + } + } catch (error) { + // Persist the request even when the first turn fails so the chat has a + // transcript (user message) and the error row can offer Retry → continue, + // matching ordinary addMessage failures (cycle 50) and hosted UX. + const now = new Date().toISOString(); + const aborted = abortController.signal.aborted; + const failed: AiRequest = { + id: reqId, + createdAt: now, + updatedAt: now, + userId: LOCAL_BYOK_USER_ID, + gameId: gameId || null, + gameProjectJson: gameProjectJson || null, + status: aborted ? 'suspended' : 'error', + mode: mode || 'orchestrator', + aiConfiguration: aiConfiguration || { presetId: 'default' }, + toolsVersion: 'v14', + toolOptions: null, + error: aborted + ? null + : { + code: 'server_error', + message: (error && error.message) || String(error), + }, + output, + lastUserMessagePriceInCredits: 0, + totalPriceInCredits: 0, + }; + localAiRequestsCache[reqId] = failed; + saveLocalAiRequests(); + return failed; + } finally { + releaseTurnAbortController(reqId); + delete pendingCreateAiRequestIds[reqId]; + delete localAiRequestPartialContent[reqId]; + } + + const assistantMessage = parseAssistantMessage( + assistantResponse, + assistantMsgId + ); + output.push(assistantMessage); + + // Same accounting as addMessage / sub-agent turns: the chat's local cost + // meter must include the first turn, not only later continues. + // Both meters share one prompt size: messages plus the tools sent with the + // turn. The occupancy divides it by the input budget for the gauge; the + // cost meter adds it (a provider bills the schema like any prompt token). + const promptTokens = + estimateMessagesTokens(budgetedMessages) + + estimateToolsTokens(GDEVELOP_OPENAI_TOOLS); + localAiRequestContextTokens[reqId] = promptTokens; + addTokenUsage(reqId, promptTokens, completionOutputTokens(assistantResponse)); + + const now = new Date().toISOString(); + const aiRequest: AiRequest = { + id: reqId, + createdAt: now, + updatedAt: now, + userId: LOCAL_BYOK_USER_ID, + gameId: gameId || null, + gameProjectJson: gameProjectJson || null, + status: 'ready', + mode: mode || 'orchestrator', + aiConfiguration: aiConfiguration || { presetId: 'default' }, + toolsVersion: 'v14', + toolOptions: null, + error: null, + output, + lastUserMessagePriceInCredits: 0, + totalPriceInCredits: 0, + }; + + localAiRequestsCache[reqId] = aiRequest; + saveLocalAiRequests(); + + return aiRequest; +}; + +/** + * Client-side Add Message To AI Request implementation. + */ +export const customAddMessageToAiRequest = async ({ + aiRequestId, + userMessage, + functionCallOutputs, + gameProjectJson, + projectSpecificExtensionsSummaryJson, + mode, +}: {| + aiRequestId: string, + userMessage?: string, + functionCallOutputs?: Array, + gameProjectJson?: string | null, + projectSpecificExtensionsSummaryJson?: string | null, + mode?: 'chat' | 'agent' | 'orchestrator', +|}): Promise => { + return withLocalAiTurnLock(aiRequestId, async () => { + let existing = localAiRequestsCache[aiRequestId]; + // An explicit send/continue resumes a stopped request. The chat shows + // "Stopped. Ready when you are." after Stop, so the next user message (or + // function-call results) must flip suspended → ready *before* the turn: + // the success write-back below only keeps 'suspended' when it re-reads + // that status under this lock, which would otherwise pin the request + // stopped forever and leave RequestWriteGate / suggestions blocked. + // + // Only an explicit payload resumes: an empty continuation (retry-style + // continue turn) must not undo a Stop that lands first, and a suspend + // during this turn is still honored by the write-back re-reading the cache. + const hasExplicitPayload = + !!(userMessage && userMessage.trim()) || + !!(functionCallOutputs && functionCallOutputs.length > 0); + if (existing && existing.status === 'suspended' && hasExplicitPayload) { + existing = { + ...existing, + status: 'ready', + updatedAt: new Date().toISOString(), + }; + localAiRequestsCache[aiRequestId] = existing; + saveLocalAiRequests(); + } + if (!existing) { + existing = { + id: aiRequestId, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + userId: LOCAL_BYOK_USER_ID, + status: 'ready', + output: [], + error: null, + }; + } + + // `existing.output` is a persisted field and the loader validates only the + // request's id/status, so a truthy non-array (an object, a number) is + // possible and the spread would throw 'is not iterable', failing the whole + // turn. + const output = [...(Array.isArray(existing.output) ? existing.output : [])]; + + if (functionCallOutputs && functionCallOutputs.length > 0) { + for (const fcOutput of functionCallOutputs) { + // Network-supplied: a hole would throw on the property read. + if (!fcOutput || typeof fcOutput !== 'object') continue; + output.push({ + type: 'function_call_output', + call_id: fcOutput.call_id, + output: fcOutput.output, + messageId: `msg-fco-${Date.now()}-${Math.random() + .toString(36) + .substr(2, 4)}`, + }); + } + } + + if (userMessage && userMessage.trim()) { + output.push({ + type: 'message', + status: 'completed', + role: 'user', + content: [ + { + type: 'user_request', + status: 'completed', + text: userMessage, + }, + ], + messageId: `msg-user-${Date.now()}`, + }); + } + + // The role a studio sub-agent was spawned with must shape every turn, not + // just the first: keep its prompt and tool subset. A top-level + // orchestrator request is the studio's manager, so it uses the manager + // role too (otherwise it edits the project directly instead of delegating). + const studioRoleId = existing.studioRoleId || null; + const requestMode = mode || existing.mode; + + const systemPrompt = buildSystemPrompt({ + gameProjectJson, + projectSpecificExtensionsSummaryJson, + mode: requestMode, + role: resolveStudioRoleId(studioRoleId, requestMode).roleId, + }); + + const openAiMessages = transformGDevelopMessagesToOpenAi( + output, + systemPrompt + ); + + // Local models have small context windows: trim replayed history (never + // the system prompt or the newest exchange) before burning the request. + // A non-null but unrecognized persisted role means a sub-agent whose role + // id is stale or corrupted. Do not throw mid-turn, and do not fall back to + // the full toolset either: a read-only sub-agent (a tester) would gain + // mutations on every later turn. Fail closed to the read-only subset. A + // top-level request has no role at all and keeps every tool. + const roleTools = withoutBackendOnlyTools( + getToolsForStudioRequest(studioRoleId, requestMode) + ); + const messageBudget = getMessageBudget( + getEffectiveConfigForRequest(aiRequestId), + roleTools + ); + let budgetedMessages = trimMessagesToBudget(openAiMessages, messageBudget); + noteSystemCompactedIfChanged(aiRequestId, openAiMessages, budgetedMessages); + if (budgetedMessages.length < openAiMessages.length) { + const trimmedCount = openAiMessages.length - budgetedMessages.length; + localAiRequestTrimCounts[aiRequestId] = + (localAiRequestTrimCounts[aiRequestId] || 0) + trimmedCount; + console.warn( + `[CustomAIClient] Trimmed ${trimmedCount} old message(s) to fit the model context budget.` + ); + } + + const abortController = registerTurnAbortController(aiRequestId); + const continueConfig = getEffectiveConfigForRequest(aiRequestId); + const sendTurn = (modelOverride?: string) => + sendChatCompletion({ + messages: budgetedMessages, + tools: roleTools, + config: modelOverride + ? { ...continueConfig, model: modelOverride } + : continueConfig, + signal: abortController.signal, + onStreamDelta: partialContent => { + localAiRequestPartialContent[aiRequestId] = partialContent; + }, + onProviderTelemetry: telemetry => { + localAiRequestProviderTelemetry[aiRequestId] = { + ...telemetry, + at: new Date().toISOString(), + }; + }, + }); + let assistantResponse; + try { + try { + assistantResponse = await runTurnWithProviderRetry( + sendTurn, + abortController.signal, + continueConfig.fallbackModel + ); + } catch (error) { + // A context-overflow 400 means the request still exceeded the window + // even after the standard trim (an unknown model family defaulting too + // high, a giant single message, or a tool schema filling a small + // window). Retry ONCE with a halved message budget on the same + // endpoint before surfacing the error — the alternative is losing the + // turn entirely. Not retried on abort, and only once. + if (!abortController.signal.aborted && isContextOverflowError(error)) { + budgetedMessages = trimMessagesToBudget( + openAiMessages, + Math.max(256, Math.floor(messageBudget / 2)) + ); + console.warn( + '[CustomAIClient] The request exceeded the context window; retrying once with a smaller prompt.' + ); + assistantResponse = await sendTurn(); + } else if ( + !abortController.signal.aborted && + isNetworkLevelError(error) + ) { + console.warn( + '[CustomAIClient] The endpoint is unreachable; retrying once shortly.' + ); + await delay(NETWORK_RETRY_DELAY_MS); + assistantResponse = await sendTurn(); + } else { + throw error; + } + } + } catch (error) { + releaseTurnAbortController(aiRequestId); + delete localAiRequestPartialContent[aiRequestId]; + if (abortController.signal.aborted) { + // The turn was stopped by the user (suspended): keep the suspended + // cache copy — the turn's answer (if any arrived) is not written back + // and no error surfaces for a stop the user asked for. + return localAiRequestsCache[aiRequestId] || existing; + } + // Terminal model failure: flip status to error so the chat's error row + // (and Retry → continue turn) is offered, matching hosted /action/retry + // UX. Returning (not throwing) lets the container's updateAiRequest + // sync the React state. Persist local `output` (user message / + // function-call outputs) so the continue turn has the full transcript. + const current = localAiRequestsCache[aiRequestId] || existing; + // Persisted output is not shape-validated: skip holes and scalars so a + // malformed entry cannot replace the real error with a TypeError. + const existingIds = new Set( + (existing.output || []) + .filter(message => message && typeof message === 'object') + .map(message => message.messageId) + ); + const cacheOnly = (current.output || []).filter( + message => + message && + typeof message === 'object' && + !existingIds.has(message.messageId) && + !output.some( + own => + own && + typeof own === 'object' && + own.messageId === message.messageId + ) + ); + const failed: AiRequest = { + ...current, + output: [...output, ...cacheOnly], + status: 'error', + updatedAt: new Date().toISOString(), + error: { + code: 'server_error', + message: (error && error.message) || String(error), + }, + }; + localAiRequestsCache[aiRequestId] = failed; + saveLocalAiRequests(); + return failed; + } + releaseTurnAbortController(aiRequestId); + delete localAiRequestPartialContent[aiRequestId]; + // Occupancy is the whole prompt (messages + the role tools sent), over + // the input budget: the gauge reports how full the window is now. The + // cost meter shares the same prompt size. + const promptTokens = + estimateMessagesTokens(budgetedMessages) + estimateToolsTokens(roleTools); + localAiRequestContextTokens[aiRequestId] = promptTokens; + addTokenUsage( + aiRequestId, + promptTokens, + completionOutputTokens(assistantResponse) + ); + + const assistantMsgId = `msg-asst-${Date.now()}`; + const assistantMessage = parseAssistantMessage( + assistantResponse, + assistantMsgId + ); + output.push(assistantMessage); + + const currentCachedRequest = localAiRequestsCache[aiRequestId] || existing; + // A suggestion or suspension write that landed while the model was + // answering must not be discarded (W5): re-insert cache-only messages + // before the turn's own additions. + // `output` is a copy of the persisted array plus this turn's messages, so + // a hole reaches this map; filter it like the other persisted-output reads. + const turnIds = new Set( + output + .filter(message => message && typeof message === 'object') + .map(message => message.messageId) + ); + const cacheOnlyMessages = (currentCachedRequest.output || []).filter( + message => message && !turnIds.has(message.messageId) + ); + // For a message that existed before this turn, the CACHE is the source of + // truth: a concurrent gated write may have REWRITTEN it in place (e.g. the + // studio flipping a plan task to `done` after a sub-agent reported). The + // turn's `output` is a snapshot from turn start, so keeping its copy would + // silently clobber that rewrite - the flip has the same messageId, so the + // cache-only path below cannot recover it. Messages the turn produced have + // fresh ids and are absent from the cache, so they are kept as-is. + const cachedByMessageId = new Map(); + for (const message of currentCachedRequest.output || []) { + if (message && typeof message === 'object' && message.messageId) { + cachedByMessageId.set(message.messageId, message); + } + } + const reconciledOutput = output.map(message => + message && + typeof message === 'object' && + message.messageId && + cachedByMessageId.has(message.messageId) + ? cachedByMessageId.get(message.messageId) + : message + ); + let merged = reconciledOutput; + if (cacheOnlyMessages.length > 0) { + const appendedThisTurn = (userMessage && userMessage.trim() ? 1 : 0) + 1; + const turnStart = Math.max(0, reconciledOutput.length - appendedThisTurn); + merged = [ + ...reconciledOutput.slice(0, turnStart), + ...cacheOnlyMessages, + ...reconciledOutput.slice(turnStart), + ]; + } + const updatedAiRequest: AiRequest = { + // Read the cache again, inside the lock: a suspend or a suggestion write + // that landed while the model was answering must not be discarded. + ...currentCachedRequest, + updatedAt: new Date().toISOString(), + // A request suspended during the turn stays suspended: writing the answer + // back must not silently resume it. It keeps the new output so the user + // can read what the agent produced before it was stopped. + status: + currentCachedRequest.status === 'suspended' ? 'suspended' : 'ready', + // A successful turn clears any prior terminal error: leave it set and + // FinalizeSubAgents / any status-agnostic reader sees a false failure. + error: null, + output: merged, + }; + + localAiRequestsCache[aiRequestId] = updatedAiRequest; + saveLocalAiRequests(); + + return updatedAiRequest; + }); +}; + +/** + * Create the `AiRequest` of one studio sub-agent: a child of `parentAiRequestId`, + * with the role's prompt and tool subset, that runs exactly one model turn. + * + * The child is never listed as a top-level chat: it has a `parentAiRequestId`, so + * the history ignores it (`AiRequestContext.updateAiRequest`). + */ +export const customCreateSubAgentAiRequest = async ({ + parentAiRequestId, + roleId, + userRequest, + gameProjectJson, + projectSpecificExtensionsSummaryJson, + spawnContextNote, +}: {| + parentAiRequestId: string, + roleId: string, + userRequest: string, + gameProjectJson: string | null, + projectSpecificExtensionsSummaryJson: string | null, + spawnContextNote?: string | null, +|}): Promise => { + const reqId = `local-ai-${Date.now()}-${Math.random() + .toString(36) + .substr(2, 7)}`; + + return withLocalAiTurnLock(reqId, async () => { + const userMessage: AiRequestUserMessage = { + type: 'message', + status: 'completed', + role: 'user', + content: [ + { + type: 'user_request', + status: 'completed', + text: userRequest, + }, + ], + messageId: `msg-user-${Date.now()}`, + }; + + const output: Array = [userMessage]; + + const systemPrompt = buildSystemPrompt({ + gameProjectJson, + projectSpecificExtensionsSummaryJson, + mode: 'agent', + role: roleId, + spawnContextNote, + }); + + const openAiMessages = transformGDevelopMessagesToOpenAi( + output, + systemPrompt + ); + + // A sub-agent that is CREATED with an unrecognized role must not receive + // the full toolset: `getToolsForRole` throws precisely so a role that is + // meant to be read-only cannot silently gain mutations. Fail closed to the + // read-only subset instead of failing open, which is why this differs from + // the continue-turn fallback above (there the role only re-derives tools + // for an already-created agent). + const subAgentTools = withoutBackendOnlyTools( + isStudioRoleId(roleId) + ? getToolsForRole((roleId: any), GDEVELOP_OPENAI_TOOLS) + : READ_ONLY_OPENAI_TOOLS + ); + const subAgentMessageBudget = getMessageBudget( + getEffectiveConfigForRequest(parentAiRequestId || reqId), + subAgentTools + ); + let budgetedMessages = trimMessagesToBudget( + openAiMessages, + subAgentMessageBudget + ); + noteSystemCompactedIfChanged( + parentAiRequestId || reqId, + openAiMessages, + budgetedMessages + ); + if (budgetedMessages.length < openAiMessages.length) { + const trimmedCount = openAiMessages.length - budgetedMessages.length; + const countKey = parentAiRequestId || reqId; + localAiRequestTrimCounts[countKey] = + (localAiRequestTrimCounts[countKey] || 0) + trimmedCount; + console.warn( + `[CustomAIClient] Trimmed ${trimmedCount} old message(s) to fit the model context budget.` + ); + } + + const abortController = registerTurnAbortController( + reqId, + parentAiRequestId + ); + const subAgentConfig = getEffectiveConfigForRequest( + parentAiRequestId || reqId + ); + const sendTurn = (modelOverride?: string) => + sendChatCompletion({ + messages: budgetedMessages, + tools: subAgentTools, + config: modelOverride + ? { ...subAgentConfig, model: modelOverride } + : subAgentConfig, + signal: abortController.signal, + // Attribute the partial content to the parent chat, which is what the + // UI polls while a sub-agent runs (the sub-agent request is internal). + onStreamDelta: partialContent => { + localAiRequestPartialContent[ + parentAiRequestId || reqId + ] = partialContent; + }, + // Sub-agent telemetry attributes to the parent chat, the key the UI + // and the other registries use. + onProviderTelemetry: telemetry => { + localAiRequestProviderTelemetry[parentAiRequestId || reqId] = { + ...telemetry, + at: new Date().toISOString(), + }; + }, + }); + let assistantResponse; + try { + try { + assistantResponse = await runTurnWithProviderRetry( + sendTurn, + abortController.signal, + subAgentConfig.fallbackModel + ); + } catch (error) { + // Sub-agents receive the project JSON too, so they can overflow the + // same way: one retry at a halved budget before failing the child. + if (!abortController.signal.aborted && isContextOverflowError(error)) { + budgetedMessages = trimMessagesToBudget( + openAiMessages, + Math.max(256, Math.floor(subAgentMessageBudget / 2)) + ); + console.warn( + '[CustomAIClient] The sub-agent request exceeded the context window; retrying once with a smaller prompt.' + ); + assistantResponse = await sendTurn(); + } else if ( + !abortController.signal.aborted && + isNetworkLevelError(error) + ) { + console.warn( + '[CustomAIClient] The endpoint is unreachable; retrying once shortly.' + ); + await delay(NETWORK_RETRY_DELAY_MS); + assistantResponse = await sendTurn(); + } else { + throw error; + } + } + } finally { + releaseTurnAbortController(reqId); + delete localAiRequestPartialContent[parentAiRequestId || reqId]; + } + // Sub-agent occupancy is attributed to the parent chat, which is what the + // UI reads (the same key its trims and partial content use) — counting + // the sub-agent tools actually sent, not the full schema. Shared with the + // cost meter like the other turn paths. + const promptTokens = + estimateMessagesTokens(budgetedMessages) + + estimateToolsTokens(subAgentTools); + localAiRequestContextTokens[parentAiRequestId || reqId] = promptTokens; + // Cost is attributed to the parent chat (the UI's meter), and ALSO to the + // sub-agent's own id so per-agent usage is auditable (the child request is + // internal, but its id is known to the studio and can be read with + // customGetAiRequestTokenTotal). The parent attribution is unchanged. + addTokenUsage( + parentAiRequestId || reqId, + promptTokens, + completionOutputTokens(assistantResponse) + ); + if (parentAiRequestId) { + addTokenUsage( + reqId, + promptTokens, + completionOutputTokens(assistantResponse) + ); + } + + const assistantMessage = parseAssistantMessage( + assistantResponse, + `msg-asst-${Date.now()}` + ); + output.push(assistantMessage); + + const now = new Date().toISOString(); + const aiRequest: AiRequest = { + id: reqId, + createdAt: now, + updatedAt: now, + userId: LOCAL_BYOK_USER_ID, + gameProjectJson: gameProjectJson || null, + status: 'ready', + mode: 'agent', + aiConfiguration: { presetId: 'default' }, + toolsVersion: 'v14', + toolOptions: null, + parentAiRequestId: parentAiRequestId, + studioRoleId: roleId, + error: null, + output, + lastUserMessagePriceInCredits: 0, + totalPriceInCredits: 0, + }; + + localAiRequestsCache[reqId] = aiRequest; + saveLocalAiRequests(); + + return aiRequest; + }); +}; + +/** + * Client-side Get AI Request. + */ +export const customGetAiRequest = (aiRequestId: string): AiRequest => { + const request = localAiRequestsCache[aiRequestId]; + if (request) return request; + + const now = new Date().toISOString(); + const fallback: AiRequest = { + id: aiRequestId, + createdAt: now, + updatedAt: now, + userId: LOCAL_BYOK_USER_ID, + status: 'ready', + error: null, + output: [], + }; + return fallback; +}; + +/** + * Client-side Get AI Requests list. + */ +export const customGetAiRequests = (): { + aiRequests: Array, + nextPageUri: ?string, +} => { + loadLocalAiRequests(); + const requests = Object.values(localAiRequestsCache); + requests.sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ); + return { + aiRequests: requests, + nextPageUri: null, + }; +}; + +/** + * Client-side Get AI Request Statuses. + */ +export const customGetAiRequestStatuses = ( + aiRequestIds: Array +): Array<{| id: string, status: GenerationStatus, userId: ?string |}> => { + return aiRequestIds.map(id => { + const req = localAiRequestsCache[id]; + return { + id, + status: req ? req.status : 'ready', + userId: LOCAL_BYOK_USER_ID, + }; + }); +}; + +/** + * Client-side Suspend AI Request. + */ +export const customSuspendAiRequest = (aiRequestId: string): AiRequest => { + const existing = localAiRequestsCache[aiRequestId]; + if (existing) { + // Replace the cached request rather than mutating it in place: a model turn + // may be holding a reference to it and will write its own copy back. + const now = new Date().toISOString(); + const suspended: AiRequest = { + ...existing, + status: 'suspended', + updatedAt: now, + }; + localAiRequestsCache[aiRequestId] = suspended; + // A parent Stop must not leave BYOK-burning children running: cancel any + // in-flight model turn (the aborted turn keeps the suspended cache copy) + // and suspend every sub-agent of this request too. + abortTurnsForRequest(aiRequestId); + for (const key of Object.keys(localAiRequestsCache)) { + const child = localAiRequestsCache[key]; + if (child && child.parentAiRequestId === aiRequestId) { + localAiRequestsCache[key] = { + ...child, + status: 'suspended', + updatedAt: now, + }; + } + } + saveLocalAiRequests(); + return suspended; + } + return customGetAiRequest(aiRequestId); +}; + +/** + * Client-side update write-through: a mutated request is written back to the + * local cache so plan flips and the loop guard's `status: 'error'` are not + * reverted by the next cache fetch. Content-idempotent. + */ +export const customUpdateAiRequest = (aiRequest: AiRequest): void => { + localAiRequestsCache[aiRequest.id] = aiRequest; + saveLocalAiRequests(); +}; + +/** + * Client-side Delete AI Request: drop it (and any per-request registries) + * from the local cache. Mirrors the hosted DELETE /ai-request/{id}. + */ +export const customDeleteAiRequest = (aiRequestId: string): void => { + // Cancel any in-flight turn before dropping the request so a late write + // cannot resurrect it in the cache. + abortTurnsForRequest(aiRequestId); + delete localAiRequestsCache[aiRequestId]; + delete localAiRequestTrimCounts[aiRequestId]; + delete localAiRequestSystemCompacted[aiRequestId]; + delete localAiRequestTokenTotals[aiRequestId]; + delete localAiRequestContextTokens[aiRequestId]; + delete localAiRequestModelOverrides[aiRequestId]; + delete localAiRequestPartialContent[aiRequestId]; + delete localAiRequestProviderTelemetry[aiRequestId]; + delete localAiTurnTails[aiRequestId]; + saveLocalAiRequests(); + // The token total lived in its own persisted map, so prune it there too. + saveLocalAiRequestTokenTotals(); +}; + +/** + * Client-side PATCH of the user-editable attributes (title / archived), + * mirroring the hosted PATCH /ai-request/{id}. + */ +export const customPatchAiRequestAttributes = ( + aiRequestId: string, + attributes: {| title?: string | null, archived?: boolean |} +): AiRequest => { + const existing = localAiRequestsCache[aiRequestId]; + if (!existing) return customGetAiRequest(aiRequestId); + + const now = new Date().toISOString(); + // Replace the cached request rather than mutating it in place (see + // customSuspendAiRequest): a model turn may hold a reference to the old copy. + const updated: AiRequest = { ...existing, updatedAt: now }; + if (attributes.title !== undefined) updated.title = attributes.title; + if (attributes.archived !== undefined) { + updated.archivedAt = attributes.archived ? now : null; + } + localAiRequestsCache[aiRequestId] = updated; + saveLocalAiRequests(); + return updated; +}; + +/** + * Client-side Retry after a failed turn: clear the terminal error, then + * re-run one model turn with no new user message so the local path picks up + * from the last message already in the cache (mirrors hosted + * `/action/retry`, which resumes generation server-side). + */ +export const customRetryAiRequest = async ( + aiRequestId: string +): Promise => { + const existing = localAiRequestsCache[aiRequestId]; + if (!existing) return customGetAiRequest(aiRequestId); + if (existing.status !== 'error') return existing; + + const outputLength = (existing.output || []).length; + const retried: AiRequest = { + ...existing, + status: 'ready', + error: null, + updatedAt: new Date().toISOString(), + // Track retries the same way the hosted API does so canRetryAiRequest + // still applies to local chats after they are resumed. + retriedAfterMessagesCount: outputLength, + retriesInARowCount: (existing.retriesInARowCount || 0) + 1, + }; + localAiRequestsCache[aiRequestId] = retried; + saveLocalAiRequests(); + + // No userMessage / functionCallOutputs: the turn continues from existing output. + try { + return await customAddMessageToAiRequest({ aiRequestId }); + } catch (error) { + // User stopped the continued turn — leave the suspended cache copy alone. + const current = localAiRequestsCache[aiRequestId] || retried; + if (current.status === 'suspended') return current; + const failed: AiRequest = { + ...current, + status: 'error', + updatedAt: new Date().toISOString(), + error: { + code: 'server_error', + message: (error && error.message) || String(error), + }, + }; + localAiRequestsCache[aiRequestId] = failed; + saveLocalAiRequests(); + // Return (do not throw) so the container's updateAiRequest syncs the + // new error + retriesInARowCount; the error row still offers Retry. + return failed; + } +}; + +/** + * Client-side Fork AI Request. + */ +export const customForkAiRequest = ( + aiRequestId: string, + upToMessageId?: string +): AiRequest => { + const original = localAiRequestsCache[aiRequestId]; + const newReqId = `local-ai-${Date.now()}-${Math.random() + .toString(36) + .substr(2, 7)}`; + let output = []; + + if (original && original.output) { + if (upToMessageId) { + // Persisted output is not shape-validated: a null hole would throw on + // the read below, losing the fork entirely. + const idx = original.output.findIndex( + m => m && m.messageId === upToMessageId + ); + output = + idx >= 0 ? original.output.slice(0, idx + 1) : [...original.output]; + } else { + output = [...original.output]; + } + } + + const now = new Date().toISOString(); + // Clear any terminal error from the source chat: a fork starts ready. Keep + // the per-request model override so the fork continues on the same model. + const modelOverride = localAiRequestModelOverrides[aiRequestId]; + if (modelOverride) { + localAiRequestModelOverrides[newReqId] = modelOverride; + } + const forked: AiRequest = { + ...(original || {}), + id: newReqId, + createdAt: now, + updatedAt: now, + userId: LOCAL_BYOK_USER_ID, + status: 'ready', + error: null, + forkedFromAiRequestId: aiRequestId, + output, + }; + + localAiRequestsCache[newReqId] = forked; + saveLocalAiRequests(); + return forked; +}; + +/** + * Coerce model output into a safe AiRequestSuggestions shape. + * SuggestionLines calls `.length`/`.map` on suggestions.suggestions without a + * further guard — a truthy non-array (or missing fields) would crash the chat. + * Returns null when the payload is unusable so callers fall back to defaults. + */ +export const sanitizeAiRequestSuggestions = ( + parsed: mixed +): AiRequestSuggestions | null => { + if (!parsed || typeof parsed !== 'object') return null; + const source: Object = parsed; + const rawItems = source.suggestions; + if (!Array.isArray(rawItems) || rawItems.length === 0) return null; + const items = []; + for (let i = 0; i < rawItems.length; i++) { + const item = rawItems[i]; + if (!item || typeof item !== 'object') return null; + const title = typeof item.title === 'string' ? item.title.trim() : ''; + const suggestedMessage = + typeof item.suggestedMessage === 'string' + ? item.suggestedMessage.trim() + : ''; + if (!title || !suggestedMessage) return null; + items.push({ + title, + suggestedMessage, + }); + } + const explanationMessage = + typeof source.explanationMessage === 'string' && + source.explanationMessage.trim() + ? source.explanationMessage.trim() + : 'Here are some things you can do next:'; + return { + explanationMessage, + suggestions: items, + }; +}; + +/** + * Client-side Suggestions Generator. + */ +export const customGetAiRequestSuggestions = async ( + aiRequestId: string +): Promise<{| + ...AiRequest, + suggestions: Array<{| title: string, suggestedMessage: string |}>, + explanationMessage: string, +|}> => { + const req = customGetAiRequest(aiRequestId); + const defaultSuggestions = { + explanationMessage: 'Here are some things you can do next:', + suggestions: [ + { + title: 'Add Movement', + suggestedMessage: 'Add top-down movement behavior', + }, + { + title: 'Add Collisions', + suggestedMessage: 'Add collision events between player and obstacles', + }, + { + title: 'Add Sound Effects', + suggestedMessage: 'Play a sound when collecting items', + }, + ], + }; + + // Attach suggestions to the last assistant/function-output message in the + // local cache and return THAT snapshot. The chat UI reads message.suggestions + // (not the top-level fields), and callers merge this return value back over + // the request — returning the pre-attach snapshot used to clobber the cache + // write, so local BYOK suggestions never reached the UI. + const attachSuggestions = ( + parsed: AiRequestSuggestions + ): {| + ...AiRequest, + suggestions: Array<{| title: string, suggestedMessage: string |}>, + explanationMessage: string, + |} => { + const topSuggestions = parsed.suggestions || defaultSuggestions.suggestions; + const topExplanation = + parsed.explanationMessage || defaultSuggestions.explanationMessage; + // Re-read the cache: a model turn may have written the request back while + // the suggestion model was answering. + const latest = localAiRequestsCache[aiRequestId] || req; + const output = latest.output || []; + if (output.length === 0) { + return { + ...latest, + suggestions: topSuggestions, + explanationMessage: topExplanation, + }; + } + const lastMsgIndex = output.length - 1; + const lastMsg = output[lastMsgIndex]; + // Persisted output is not shape-validated: a null hole threw out of both + // the try and the catch path below, losing suggestions entirely. + const canAttach = + !!lastMsg && + ((lastMsg.type === 'message' && lastMsg.role === 'assistant') || + lastMsg.type === 'function_call_output'); + if (!canAttach) { + return { + ...latest, + suggestions: topSuggestions, + explanationMessage: topExplanation, + }; + } + // Replace the message and the request rather than mutating in place: a + // model turn holding this request will write its own copy back, and an + // in-place edit would be silently discarded by that write. + const updatedOutput = [...output]; + // $FlowFixMe[incompatible-type] - canAttach narrows to a type that accepts suggestions. + updatedOutput[lastMsgIndex] = { ...lastMsg, suggestions: parsed }; + const updated: AiRequest = { + ...latest, + output: updatedOutput, + updatedAt: new Date().toISOString(), + }; + localAiRequestsCache[aiRequestId] = updated; + saveLocalAiRequests(); + return { + ...updated, + suggestions: topSuggestions, + explanationMessage: topExplanation, + }; + }; + + try { + const prompt = `Based on the current GDevelop game project and conversation, provide 2 to 3 concise, helpful next step suggestions for the game creator. +Return your response STRICTLY as a JSON object with this format: +{ + "explanationMessage": "Here are some things you can do next:", + "suggestions": [ + { "title": "Add Enemy Patrol", "suggestedMessage": "Add an enemy with patrol behavior" }, + { "title": "Add Sound Effects", "suggestedMessage": "Play sound when collecting coins" } + ] +}`; + + // The replayed history is budgeted like any other turn: without a + // trim, a long chat went over a small local model's window, errored, and + // fell back to defaults after burning the request. No tools ride this + // call, so the messages get the whole input budget. + const suggestionMessages = trimMessagesToBudget( + [ + ...transformGDevelopMessagesToOpenAi(req.output || []), + { role: 'user', content: prompt }, + ], + getMessageBudget(getEffectiveConfigForRequest(aiRequestId), []) + ); + const res = await sendChatCompletion({ + messages: suggestionMessages, + config: getEffectiveConfigForRequest(aiRequestId), + }); + + const clean = (res.content || '') + .replace(/```(?:json)?/gi, '') + .replace(/```/gi, '') + .trim(); + const sanitized = sanitizeAiRequestSuggestions(JSON.parse(clean)); + if (!sanitized) { + return attachSuggestions({ + explanationMessage: defaultSuggestions.explanationMessage, + suggestions: defaultSuggestions.suggestions, + }); + } + return attachSuggestions(sanitized); + } catch (err) { + // Malformed JSON or a dropped connection: still surface defaults on the + // last message so the chat is not stuck without next-step actions. + return attachSuggestions({ + explanationMessage: defaultSuggestions.explanationMessage, + suggestions: defaultSuggestions.suggestions, + }); + } +}; + +/** + * Client-side Create AI Generated Event implementation. + */ +export const customCreateAiGeneratedEvent = async ({ + sceneName, + eventsDescription, + eventBatches, + extensionNamesList, + objectsList, + existingEventsAsText, + aiRequestId, +}: {| + sceneName: string, + eventsDescription: string | null, + eventBatches?: any, + extensionNamesList?: string, + objectsList?: string, + existingEventsAsText?: string, + aiRequestId?: string | null, +|}): Promise => { + // The existing events are the only unbounded input: compact them to + // whatever the input budget leaves after the fixed template and the other + // inputs, keeping the head (setup and early logic are the structural + // context the model needs most). No tools ride this call, so the whole + // input budget is available. Without this, a big scene went over a small + // local model's window and the whole generation was lost. + const inputBudget = getTokenBudget( + getEffectiveConfigForRequest(aiRequestId || '') + ); + const promptTail = ` +{ + "operationName": "insert", + "operationTargetEvent": null, + "generatedEvents": "[]", + "diagnosticLines": [], + "undeclaredVariables": [], + "undeclaredObjectVariables": {}, + "missingObjectBehaviors": {}, + "missingResources": [] +} +Ensure generatedEvents is a JSON string of standard GDevelop event objects (e.g. StandardEvent with conditions and actions).`; + const buildPrompt = (eventsBlock: string): string => { + return ( + `You are the GDevelop Event Generation Engine. +Generate the GDevelop events in JSON format matching GDevelop's internal event structure for scene "${sceneName}". +Description of events to generate: "${eventsDescription || ''}" +Available Objects: ${objectsList || 'None'} +Available Extensions: ${extensionNamesList || 'Builtin'} +Existing Events in Scene: +${eventsBlock} + +Return a valid JSON object with the following structure:` + promptTail + ); + }; + const systemContent = 'You are a GDevelop 5 Event generator.'; + const measurePrompt = (eventsBlock: string): number => + estimateMessagesTokens([ + { role: 'system', content: systemContent }, + { role: 'user', content: buildPrompt(eventsBlock) }, + ]); + let prompt = buildPrompt(existingEventsAsText || 'None'); + if ( + measurePrompt(existingEventsAsText || 'None') > inputBudget && + existingEventsAsText + ) { + const marker = `\n[... ${ + existingEventsAsText.length + } characters of existing events truncated to fit the model context window ...]`; + // Measured in the same estimator the turns use, with a margin for its + // ceiling rounding: token/character conversions are approximate by design. + const allowance = Math.max( + 0, + inputBudget - measurePrompt('') - estimateTokens(marker) - 64 + ); + prompt = buildPrompt( + sliceToTokenBudget(existingEventsAsText, allowance) + marker + ); + } + + try { + const res = await sendChatCompletion({ + messages: [ + { role: 'system', content: systemContent }, + { role: 'user', content: prompt }, + ], + config: getEffectiveConfigForRequest(aiRequestId || ''), + }); + + const clean = (res.content || '') + .replace(/```(?:json)?/gi, '') + .replace(/```/gi, '') + .trim(); + let changeData; + try { + changeData = JSON.parse(clean); + } catch (parseErr) { + // Never report a silent, empty 'success': the editor would insert + // nothing while telling the user the generation worked. + return { + creationSucceeded: false, + errorMessage: + 'The model response could not be parsed as JSON. Try rephrasing the request, lowering the temperature, or using a stronger model.', + }; + } + + // The generatedEvents payload must be a JSON string (or array) holding a + // list of events; anything else would corrupt the events sheet. + const rawGeneratedEvents = changeData.generatedEvents; + let generatedEventsValid = true; + let generatedEventsText; + if (typeof rawGeneratedEvents === 'string') { + generatedEventsText = rawGeneratedEvents; + try { + const parsedEvents = JSON.parse(rawGeneratedEvents); + generatedEventsValid = Array.isArray(parsedEvents); + } catch (eventsParseErr) { + generatedEventsValid = false; + } + } else if (Array.isArray(rawGeneratedEvents)) { + generatedEventsText = JSON.stringify(rawGeneratedEvents); + } else if ( + rawGeneratedEvents === undefined || + rawGeneratedEvents === null + ) { + generatedEventsText = '[]'; + } else { + generatedEventsText = JSON.stringify(rawGeneratedEvents); + generatedEventsValid = false; + } + if (!generatedEventsValid) { + return { + creationSucceeded: false, + errorMessage: + 'The model response did not contain a valid list of generated events (generatedEvents must be a JSON string or array of GDevelop event objects).', + }; + } + + // Every array-typed field is model-authored: `X || []` keeps a truthy + // non-array (a string, an object, a number) and the consumer then calls + // `.join` or iterates it — `diagnosticLines.join('\n')` in particular threw + // on a bare string. Coerce each to an array so a malformed field degrades + // to "no entries" instead of breaking the whole generation result. + const asArray = (value: any): Array => + Array.isArray(value) ? value : []; + const asObject = (value: any): Object => + value && typeof value === 'object' && !Array.isArray(value) ? value : {}; + + const aiGeneratedEvent: AiGeneratedEvent = { + id: `local-evt-${Date.now()}`, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + userId: LOCAL_BYOK_USER_ID, + status: 'ready', + partialGameProjectJson: '', + eventsDescription: eventsDescription || null, + eventBatches: eventBatches || null, + extensionNamesList: extensionNamesList || '', + objectsList: objectsList || '', + existingEventsAsText: existingEventsAsText || '', + existingEventsJson: null, + existingEventsJsonUserRelativeKey: null, + resultMessage: 'Events generated locally via custom AI endpoint.', + changes: [ + { + operationName: changeData.operationName || 'insert', + operationTargetEvent: changeData.operationTargetEvent || null, + isEventsJsonValid: true, + generatedEvents: generatedEventsText, + areEventsValid: true, + extensionNames: asArray(changeData.extensionNames), + diagnosticLines: asArray(changeData.diagnosticLines), + undeclaredVariables: asArray(changeData.undeclaredVariables), + undeclaredObjectVariables: asObject( + changeData.undeclaredObjectVariables + ), + missingObjectBehaviors: asObject(changeData.missingObjectBehaviors), + missingResources: asArray(changeData.missingResources), + }, + ], + error: null, + stats: null, + }; + + return { + creationSucceeded: true, + aiGeneratedEvent, + }; + } catch (error) { + return { + creationSucceeded: false, + errorMessage: + error.message || 'Failed to generate events with custom AI endpoint.', + }; + } +}; + +/** + * Test connectivity with the configured custom endpoint. + */ +export const testConnection = async ( + customConfig?: $Shape +): Promise<{| + success: boolean, + message: string, + models?: Array, +|}> => { + const config = { + ...getCustomEndpointConfig(), + ...(customConfig || {}), + }; + const baseUrl = normalizeBaseUrl(config.baseUrl); + + // First attempt: Try GET /models + try { + const modelsUrl = getEndpointUrl(baseUrl, '/models'); + // Same local-first header policy as sendChatCompletion: no attribution + // headers that a local server's CORS preflight would reject. + const headers: { [string]: string } = {}; + const testHeaders = config.customHeaders || {}; + for (const headerName of Object.keys(testHeaders)) { + const headerValue = testHeaders[headerName]; + if (isSafeRequestHeader(headerName, headerValue)) { + headers[headerName] = headerValue; + } + } + if (config.apiKey && config.apiKey.trim()) { + headers['Authorization'] = `Bearer ${config.apiKey.trim()}`; + } + + const response = await axios.get(modelsUrl, { headers, timeout: 5000 }); + if (response.status === 200 && response.data) { + const data = response.data.data || response.data.models || response.data; + const models = Array.isArray(data) + ? data.map(item => item.id || item.name || String(item)).filter(Boolean) + : []; + const configuredModel = (config.model || '').trim(); + const modelWarning = + configuredModel && + models.length > 0 && + !models.some( + model => + model === configuredModel || model.startsWith(`${configuredModel}:`) + ) + ? ` Warning: the configured model '${configuredModel}' was not found in the endpoint's model list — check for a typo in the AI preferences.` + : ''; + return { + success: true, + message: `Successfully connected to endpoint. Found ${ + models.length + } model(s).${modelWarning}`, + models, + }; + } + } catch (err) { + // If /models failed, fallback to a lightweight completion test + } + + // Second attempt: Minimal chat completion + try { + const res = await sendChatCompletion({ + messages: [{ role: 'user', content: 'Say "OK"' }], + config, + }); + // Success only when the model actually answered. A completion with no + // text (a reasoner that wrote only reasoning_content, or a bare body) + // validated nothing, and reporting "successfully connected" for it sends + // the user off with a broken setup. + const answer = typeof res.content === 'string' ? res.content.trim() : ''; + if (!answer) { + return { + success: false, + message: + 'The endpoint answered, but the model returned no text. It may be a reasoning-only model, or the endpoint may not support chat completions — check the model name in the AI preferences.', + }; + } + return { + success: true, + message: `Successfully connected! Model responded: ${answer}`, + }; + } catch (err) { + return { + success: false, + message: `Connection failed: ${err.message || + 'Failed to connect to custom endpoint.'}`, + }; + } +}; + +/** + * Client-side Asset Search. + */ +export const customCreateAssetSearch = async ({ + searchTerms, + objectType, +}: {| + searchTerms: string, + objectType?: string | null, +|}): Promise => { + return { + id: `local-asset-search-${Date.now()}`, + userId: LOCAL_BYOK_USER_ID, + createdAt: new Date().toISOString(), + query: { + searchTerms: [searchTerms], + objectType: objectType || '', + description: null, + twoDimensionalViewKind: null, + relatedAiRequestId: null, + lastUserMessage: null, + lastAssistantMessages: [], + }, + status: 'completed', + results: [], + }; +}; + +/** + * Client-side Resource Search. + */ +export const customCreateResourceSearch = async ({ + searchTerms, + resourceKind, +}: {| + searchTerms: string, + resourceKind: string, +|}): Promise => { + return { + id: `local-resource-search-${Date.now()}`, + userId: LOCAL_BYOK_USER_ID, + createdAt: new Date().toISOString(), + query: { + searchTerms: [searchTerms], + resourceKind, + }, + status: 'completed', + results: [], + }; +}; + +/** + * Default fallback AI configuration presets when offline or in custom endpoint mode. + */ +export const DEFAULT_LOCAL_AI_SETTINGS: AiSettings = { + aiRequest: { + presets: [ + { + mode: 'orchestrator', + id: 'default', + nameByLocale: { en: 'Custom / BYOK AI (Default)' }, + disabled: false, + isDefault: true, + }, + { + mode: 'chat', + id: 'chat-default', + nameByLocale: { en: 'Custom / BYOK Chat' }, + disabled: false, + isDefault: true, + }, + { + mode: 'agent', + id: 'agent-default', + nameByLocale: { en: 'Custom / BYOK Agent' }, + disabled: false, + isDefault: true, + }, + ], + }, +}; diff --git a/packages/ai-studio/AI/CustomAIClient.spec.js b/packages/ai-studio/AI/CustomAIClient.spec.js new file mode 100644 index 0000000..ee26feb --- /dev/null +++ b/packages/ai-studio/AI/CustomAIClient.spec.js @@ -0,0 +1,7266 @@ +// @flow +import axios from 'axios'; +import { + getCustomEndpointConfig, + setCustomEndpointConfig, + isCustomEndpointEnabled, + normalizeBaseUrl, + getEndpointUrl, + extractThinkingAndContent, + transformGDevelopMessagesToOpenAi, + parseAssistantMessage, + DEFAULT_CUSTOM_AI_CONFIG, + MAX_TIMEOUT_MS, + GDEVELOP_OPENAI_TOOLS, + SIDE_EFFECT_FREE_TOOLS, + LOCAL_BYOK_USER_ID, + _resetCustomAiClientForTesting, + customCreateAiRequest, + customCreateSubAgentAiRequest, + customAddMessageToAiRequest, + customUpdateAiRequest, + customGetAiRequest, + customGetAiRequestPartialContent, + customGetAiRequests, + customGetAiRequestStatuses, + customSuspendAiRequest, + customForkAiRequest, + customDeleteAiRequest, + customPatchAiRequestAttributes, + customRetryAiRequest, + customGetAiRequestSuggestions, + sanitizeAiRequestSuggestions, + customCreateAiGeneratedEvent, + customCreateAssetSearch, + customCreateResourceSearch, + testConnection, + sendChatCompletion, + formatProviderTelemetry, + withoutBackendOnlyTools, + BACKEND_ONLY_TOOL_NAMES, + buildSystemPrompt, + getEffectiveConfigForRequest, + parseProviderTelemetry, + customGetAiRequestProviderTelemetry, + isContextOverflowError, + isNetworkLevelError, + isRetryableProviderError, + getProviderErrorStatus, + _setProviderRetryDelayForTesting, + estimateTokens, + estimateToolsTokens, + getMessageBudget, + getTokenBudget, + estimateMessagesTokens, + trimMessagesToBudget, + compactSystemMessageToBudget, + validateToolCallArguments, + customGetAiRequestContextTrimCount, + customGetAiRequestSystemCompacted, + customHasPendingCreateAiRequest, + customAbortPendingCreateAiRequests, + customGetAiRequestTokenTotal, + customGetAiRequestContextTokens, + customSetAiRequestModelOverride, + customGetAiRequestModelOverride, + loadLocalAiRequests, + withLocalAiTurnLock, + loadLocalAiRequestModelOverridesForTesting, + loadLocalAiRequestTokenTotalsForTesting, + recordLocalAiRequestTokenUsageForTesting, + loadLocalAiSubAgentRequestsForTesting, + saveLocalAiRequests, + _resetCustomAiClientForTesting as _resetForStreamTests, +} from './CustomAIClient'; +import { isFailedAiRequestStart } from '../AiGeneration/AiRequestUtils'; + +import { + getToolsForRole, + MUTATING_TOOL_NAMES, + READ_ONLY_TOOL_NAMES, +} from '../AiGeneration/Studio/Roles'; + +jest.mock('axios'); + +describe('CustomAIClient', () => { + beforeEach(() => { + jest.clearAllMocks(); + _resetCustomAiClientForTesting(); + setCustomEndpointConfig({ + enabled: false, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'qwen2.5-coder', + temperature: 0.7, + }); + }); + + describe('Configuration & URL normalization', () => { + it('returns default configuration initially', () => { + const config = getCustomEndpointConfig(); + expect(config.baseUrl).toBe('http://localhost:11434/v1'); + expect(config.model).toBe('qwen2.5-coder'); + expect(config.temperature).toBe(0.7); + }); + + it('updates configuration and reflects enabled state', () => { + expect(isCustomEndpointEnabled()).toBe(false); + setCustomEndpointConfig({ + enabled: true, + baseUrl: 'http://localhost:8080/v1', + apiKey: 'test-key', + model: 'llama-3.2', + temperature: 0.2, + }); + expect(isCustomEndpointEnabled()).toBe(true); + const config = getCustomEndpointConfig(); + expect(config.baseUrl).toBe('http://localhost:8080/v1'); + expect(config.apiKey).toBe('test-key'); + expect(config.model).toBe('llama-3.2'); + expect(config.temperature).toBe(0.2); + }); + + it('normalizes base URLs correctly without auto-appending /v1', () => { + expect(normalizeBaseUrl('http://localhost:11434')).toBe( + 'http://localhost:11434' + ); + expect(normalizeBaseUrl('http://localhost:11434/')).toBe( + 'http://localhost:11434' + ); + expect(normalizeBaseUrl('http://localhost:11434/v1/')).toBe( + 'http://localhost:11434/v1' + ); + expect(normalizeBaseUrl('localhost:11434/v1')).toBe( + 'http://localhost:11434/v1' + ); + expect(normalizeBaseUrl('api.openai.com/v1')).toBe( + 'https://api.openai.com/v1' + ); + expect(normalizeBaseUrl('https://api.openai.com/v1')).toBe( + 'https://api.openai.com/v1' + ); + expect(normalizeBaseUrl('https://openrouter.ai/api/v1/')).toBe( + 'https://openrouter.ai/api/v1' + ); + expect(normalizeBaseUrl('')).toBe(DEFAULT_CUSTOM_AI_CONFIG.baseUrl); + }); + + it('constructs correct endpoint URLs', () => { + expect( + getEndpointUrl('http://localhost:11434/v1', '/chat/completions') + ).toBe('http://localhost:11434/v1/chat/completions'); + expect(getEndpointUrl('http://localhost:11434', '/models')).toBe( + 'http://localhost:11434/models' + ); + }); + }); + + describe('normalizeBaseUrl does not mask a malformed scheme', () => { + it('leaves a mistyped scheme alone so the error names the real problem', () => { + // Prefixing https:// to `htpp://localhost:11434` produced the host + // `htpp`, which fails later as a confusing DNS error. Returning it means + // the request fails with the "check the base URL" guidance instead. + expect(normalizeBaseUrl('htpp://localhost:11434/v1')).toBe( + 'htpp://localhost:11434/v1' + ); + expect(normalizeBaseUrl('ftp://host/v1')).toBe('ftp://host/v1'); + expect(normalizeBaseUrl('http:/localhost:11434')).toBe( + 'http:/localhost:11434' + ); + }); + + it('still repairs genuinely schemeless input', () => { + // The cases the fallback exists for must keep working. + expect(normalizeBaseUrl('localhost:11434/v1')).toBe( + 'http://localhost:11434/v1' + ); + expect(normalizeBaseUrl('localhost')).toBe('http://localhost'); + expect(normalizeBaseUrl('example.com/v1')).toBe('https://example.com/v1'); + expect(normalizeBaseUrl('')).toBe('http://localhost:11434/v1'); + }); + }); + + describe('extractThinkingAndContent', () => { + it('extracts tags from text', () => { + const input = + 'I should create an objectHere is the answer'; + const result = extractThinkingAndContent(input); + expect(result.thinking).toBe('I should create an object'); + expect(result.content).toBe('Here is the answer'); + }); + + it('handles text without tags', () => { + const input = 'Simple response with no thinking tag'; + const result = extractThinkingAndContent(input); + expect(result.thinking).toBeNull(); + expect(result.content).toBe('Simple response with no thinking tag'); + }); + + // Built by concatenation so linters/display layers never treat the tag + // as markup. + const THINK_OPEN = String.fromCharCode(60) + 'think>'; + const THINK_CLOSE = String.fromCharCode(60) + '/think>'; + + it('treats an unclosed think tag as thinking (truncated reasoning)', () => { + const input = THINK_OPEN + 'Let me create the object'; + const result = extractThinkingAndContent(input); + expect(result.thinking).toBe('Let me create the object'); + expect(result.content).toBe(''); + expect(result.cleanContent).toBe(''); + }); + + it('still extracts closed tags when both open and close exist', () => { + const input = THINK_OPEN + 'Answer' + THINK_CLOSE + 'more'; + const result = extractThinkingAndContent(input); + expect(result.thinking).toBe('Answer'); + expect(result.content).toBe('more'); + }); + + it('handles empty or non-string input', () => { + expect(extractThinkingAndContent('')).toEqual({ + thinking: null, + cleanContent: '', + content: '', + }); + // $FlowFixMe + expect(extractThinkingAndContent(null)).toEqual({ + thinking: null, + cleanContent: '', + content: '', + }); + }); + }); + + describe('transformGDevelopMessagesToOpenAi', () => { + it('skips null and scalar entries instead of throwing', () => { + // A persisted request's output is not shape-validated on load, and + // normalizePersistedMessages passes a non-message entry through + // unchanged — so a hole or a scalar reaches this loop, whose first read + // is `msg.role`. + const messages: any = [ + null, + undefined, + 'a string', + 42, + { + type: 'message', + role: 'user', + content: [{ type: 'user_request', text: 'hi' }], + }, + ]; + const openAiMessages = transformGDevelopMessagesToOpenAi(messages); + expect(openAiMessages.some(m => m.role === 'user')).toBe(true); + }); + + it('skips null entries inside content and call arrays instead of throwing', () => { + // normalizePersistedMessages validates that `output` is an array but + // passes inner entries through unchanged, so a null inside a content, + // functionCalls or functionCallOutputs array reaches every `.type`/`.id` + // read below. All four threw `Cannot read properties of null`. + const messages: any = [ + { + type: 'message', + role: 'user', + content: [{ type: 'user_request', text: 'hi' }, null, 'scalar'], + }, + { + type: 'message', + role: 'assistant', + text: 'working', + content: [ + { + type: 'function_call', + name: 'describe_instances', + call_id: 'c1', + arguments: '{}', + }, + null, + ], + functionCalls: [null], + }, + { + type: 'function_call_output', + functionCallOutputs: [null], + }, + ]; + let openAiMessages; + expect( + () => (openAiMessages = transformGDevelopMessagesToOpenAi(messages)) + ).not.toThrow(); + expect( + openAiMessages.some(m => m.role === 'user' && m.content === 'hi') + ).toBe(true); + expect( + openAiMessages.some( + m => + m.role === 'assistant' && + Array.isArray(m.tool_calls) && + m.tool_calls[0].function.name === 'describe_instances' + ) + ).toBe(true); + }); + + it('formats user messages and system prompt', () => { + const messages = [ + { + id: 'msg-1', + type: 'user', + text: 'Hello, create a player object', + createdAt: new Date().toISOString(), + }, + ]; + const openAiMessages = transformGDevelopMessagesToOpenAi( + messages, + '{"objects":[]}', + null, + 'agent' + ); + + expect(openAiMessages[0].role).toBe('system'); + expect(openAiMessages[0].content).toContain('GDevelop AI Assistant'); + expect(openAiMessages[0].content).toContain('{"objects":[]}'); + + const userMsg = openAiMessages.find(m => m.role === 'user'); + expect(userMsg).toBeDefined(); + expect(userMsg && userMsg.content).toBe('Hello, create a player object'); + }); + + it('formats assistant messages with function calls and reasoning', () => { + const messages = [ + { + id: 'msg-1', + type: 'user', + text: 'Add sprite', + createdAt: new Date().toISOString(), + }, + { + id: 'msg-2', + type: 'assistant', + text: 'I am creating the object', + thinking: 'Let us make Player', + functionCalls: [ + { + id: 'call-1', + name: 'create_or_replace_object', + callArguments: { objectName: 'Player', objectType: 'Sprite' }, + }, + ], + createdAt: new Date().toISOString(), + }, + { + id: 'msg-3', + type: 'function_call_output', + functionCallOutputs: [ + { + callId: 'call-1', + output: '{"status":"ok"}', + }, + ], + createdAt: new Date().toISOString(), + }, + ]; + + const openAiMessages = transformGDevelopMessagesToOpenAi( + messages, + null, + null, + 'agent' + ); + + const assistantMsg = openAiMessages.find(m => m.role === 'assistant'); + expect(assistantMsg).toBeDefined(); + expect(assistantMsg && assistantMsg.content).toBe( + 'I am creating the object' + ); + expect(assistantMsg && assistantMsg.tool_calls).toBeDefined(); + expect( + assistantMsg && + assistantMsg.tool_calls && + assistantMsg.tool_calls[0].function.name + ).toBe('create_or_replace_object'); + + const toolMsg = openAiMessages.find(m => m.role === 'tool'); + expect(toolMsg).toBeDefined(); + expect(toolMsg && toolMsg.tool_call_id).toBe('call-1'); + expect(toolMsg && toolMsg.content).toBe('{"status":"ok"}'); + }); + }); + + describe('parseAssistantMessage', () => { + it('parses standard OpenAI tool calls', () => { + const choice = { + message: { + role: 'assistant', + content: 'Creating a scene', + tool_calls: [ + { + id: 'call_123', + type: 'function', + function: { + name: 'create_scene', + arguments: '{"scene_name":"Level1"}', + }, + }, + ], + }, + }; + + const parsed = parseAssistantMessage(choice); + expect(parsed.text).toBe('Creating a scene'); + expect(parsed.functionCalls).toHaveLength(1); + expect(parsed.functionCalls[0].name).toBe('create_scene'); + expect(parsed.functionCalls[0].callArguments).toEqual({ + scene_name: 'Level1', + }); + }); + + it('extracts embedded JSON function call markdown if tool_calls not present for side-effect-free tools', () => { + const choice = { + message: { + role: 'assistant', + content: + 'I will inspect the instances now:\n```json\n{"name":"describe_instances","arguments":{"scene_name":"Level1"}}\n```', + }, + }; + + const parsed = parseAssistantMessage(choice); + expect(parsed.functionCalls).toHaveLength(1); + expect(parsed.functionCalls[0].name).toBe('describe_instances'); + expect(parsed.functionCalls[0].callArguments).toEqual({ + scene_name: 'Level1', + }); + }); + + it('extracts an embedded JSON call for a name that was previously dead', () => { + // The set listed describe_events/describe_variables/ + // describe_scene_layers_effects_groups, none of which the registry + // declares — so these tools were silently excluded from the fallback. + // The existing behavioural tests only used describe_instances, the one + // correct entry, which is why the typo went unnoticed. + // Each tool's own required arguments (the fallback still validates the + // schema, so an empty object would be skipped for the wrong reason). + for (const [name, args] of [ + ['read_events_source', { scene_name: 'Level1' }], + ['inspect_variables', { variable_scope: 'scene' }], + ['inspect_scene_properties_layers_effects', { scene_name: 'Level1' }], + ]) { + const parsed = parseAssistantMessage({ + message: { + role: 'assistant', + content: `\`\`\`json\n${JSON.stringify({ + name, + arguments: args, + })}\n\`\`\``, + }, + }); + expect(parsed.functionCalls).toHaveLength(1); + expect(parsed.functionCalls[0].name).toBe(name); + } + }); + + it('skips markdown tool calls whose arguments are not JSON objects', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const choice = { + message: { + role: 'assistant', + content: + '```json\n{"name":"describe_instances","arguments":["not","an","object"]}\n```', + }, + }; + const parsed = parseAssistantMessage(choice); + expect(parsed.functionCalls).toHaveLength(0); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('invalid arguments') + ); + warn.mockRestore(); + }); + + it('skips markdown tool calls that fail schema validation', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const choice = { + message: { + role: 'assistant', + content: + '```json\n{"name":"describe_instances","arguments":{"wrong_arg":1}}\n```', + }, + }; + const parsed = parseAssistantMessage(choice); + expect(parsed.functionCalls).toHaveLength(0); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('invalid arguments') + ); + warn.mockRestore(); + }); + + it('extracts reasoning_content if provided by model', () => { + const choice = { + message: { + role: 'assistant', + content: 'Done!', + reasoning_content: 'Step 1: check parameters. Step 2: verify.', + }, + }; + + const parsed = parseAssistantMessage(choice); + expect(parsed.text).toBe('Done!'); + expect(parsed.thinking).toBe('Step 1: check parameters. Step 2: verify.'); + }); + }); + + describe('GDevelop OpenAI Tool Definitions', () => { + it('defines standard GDevelop tools', () => { + const toolNames = GDEVELOP_OPENAI_TOOLS.map(t => t.function.name); + expect(toolNames).toContain('create_scene'); + expect(toolNames).toContain('create_or_replace_object'); + expect(toolNames).toContain('add_behavior'); + expect(toolNames).toContain('put_2d_instances'); + expect(toolNames).toContain('add_scene_events'); + expect(toolNames).toContain('run_script'); + expect(toolNames).toContain('create_or_update_plan'); + }); + + it('documents the run_script sandbox so models call functions instead of probing globals', () => { + // Observed failure: ten turns enumerating `gd`/globals/webpack chunks + // because the description said only "project APIs". Pin the teaching. + const runScript = GDEVELOP_OPENAI_TOOLS.find( + t => t.function.name === 'run_script' + ); + expect(runScript).toBeTruthy(); + const description = runScript.function.description; + expect(description).toContain('await'); + expect(description).toContain('never probe'); + }); + + it('advertises behavior changes as changed_properties, matching the implementation', () => { + // Observed failure (feedback-loop Run 4): the schema listed flat + // `property_name`/`new_value` while the tool reads `changed_properties` + // (like its sibling change_object_properties_effects). A model following + // the schema sent flat fields, `changed_properties` was always empty, and + // every call reported "Nothing changed. Issues: ...". Pin the array shape + // so schema and implementation cannot drift apart again. + const tool = GDEVELOP_OPENAI_TOOLS.find( + t => t.function.name === 'change_behavior_property' + ); + expect(tool).toBeTruthy(); + const parameters: any = tool && tool.function.parameters; + const properties: any = parameters.properties; + expect(properties.changed_properties.type).toBe('array'); + expect(properties.changed_properties.items.required).toEqual([ + 'property_name', + 'new_value', + ]); + expect(properties.property_name).toBeUndefined(); + expect(properties.new_value).toBeUndefined(); + expect(parameters.required).toEqual(['object_name', 'behavior_name']); + }); + + it('advertises the array args the change tools actually read', () => { + // Second half of the change_behavior_property bug: several change tools + // read a top-level array/object arg the schema never advertised, so a + // schema-conformant model could never use the capability. Pin each. + const toolProps = (name: string): any => { + const tool = GDEVELOP_OPENAI_TOOLS.find(t => t.function.name === name); + expect(tool).toBeTruthy(); + return tool ? tool.function.parameters.properties : {}; + }; + expect( + toolProps('change_object_properties_effects').changed_effects.type + ).toBe('array'); + const groups = toolProps('change_scene_properties_layers_effects_groups'); + expect(groups.changed_groups.type).toBe('array'); + expect(groups.changed_layer_effects.type).toBe('array'); + expect(groups.move_instances.type).toBe('object'); + expect( + toolProps('change_project_properties_resources').changed_resources.type + ).toBe('array'); + }); + }); + + describe('testConnection', () => { + it('successfully connects when endpoint responds', async () => { + // $FlowFixMe + axios.get.mockResolvedValueOnce({ + status: 200, + data: { + data: [{ id: 'qwen2.5-coder' }, { id: 'gpt-4o' }], + }, + }); + + const result = await testConnection({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'qwen2.5-coder', + temperature: 0.7, + }); + + expect(result.success).toBe(true); + expect(result.models).toContain('qwen2.5-coder'); + }); + + it('warns when the configured model is not in the endpoint list', async () => { + // $FlowFixMe + axios.get.mockResolvedValueOnce({ + status: 200, + data: { data: [{ id: 'qwen2.5-coder' }, { id: 'llama3.2' }] }, + }); + + const result = await testConnection({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'qwen2.6-coder-typo', + temperature: 0.7, + }); + + expect(result.success).toBe(true); + expect(result.message).toContain('was not found in the endpoint'); + }); + + it('does not warn when the configured model exists', async () => { + // $FlowFixMe + axios.get.mockResolvedValueOnce({ + status: 200, + data: { data: [{ id: 'qwen2.5-coder' }] }, + }); + + const result = await testConnection({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'qwen2.5-coder', + temperature: 0.7, + }); + + expect(result.success).toBe(true); + expect(result.message).not.toContain('was not found'); + }); + + it('handles endpoint error gracefully', async () => { + // $FlowFixMe + axios.get.mockRejectedValueOnce(new Error('Connection refused')); + // $FlowFixMe + axios.post.mockRejectedValueOnce(new Error('Connection refused')); + + const result = await testConnection({ + enabled: true, + baseUrl: 'http://localhost:9999/v1', + apiKey: '', + model: 'test-model', + temperature: 0.7, + }); + + expect(result.success).toBe(false); + expect(result.message).toContain('Connection failed'); + }); + + it('sends the API key as a Bearer token and the safe custom headers', async () => { + // $FlowFixMe + axios.get.mockResolvedValueOnce({ status: 200, data: { data: [] } }); + + await testConnection({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: 'secret-key', + model: 'qwen2.5-coder', + temperature: 0.7, + customHeaders: { 'X-Ok': 'yes', 'X-Bad': 'a\nb', Host: 'evil' }, + }); + + const headers = axios.get.mock.calls[0][1].headers; + expect(headers.Authorization).toBe('Bearer secret-key'); + expect(headers['X-Ok']).toBe('yes'); + // Header-injection and hop-by-hop names must not reach the probe either. + expect(headers['X-Bad']).toBeUndefined(); + expect(headers.Host).toBeUndefined(); + }); + + it('sends no Authorization header when the key is blank', async () => { + // $FlowFixMe + axios.get.mockResolvedValueOnce({ status: 200, data: { data: [] } }); + + await testConnection({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: ' ', + model: 'qwen2.5-coder', + temperature: 0.7, + }); + + const headers = axios.get.mock.calls[0][1].headers; + expect(headers.Authorization).toBeUndefined(); + }); + + it('falls back to a chat completion when /models is unavailable', async () => { + // $FlowFixMe + axios.get.mockRejectedValueOnce(new Error('404 not found')); + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'OK' } }] }, + }); + + const result = await testConnection({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'qwen2.5-coder', + temperature: 0.7, + }); + + expect(result.success).toBe(true); + expect(result.message).toContain('OK'); + }); + }); + + describe('tool-call argument validation', () => { + const parseOne = content => + parseAssistantMessage({ + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call-1', + function: { name: 'test_tool', arguments: content }, + }, + ], + }); + + it('parses valid JSON object arguments', () => { + const message = parseOne('{"sceneName": "Menu"}'); + const call = message.functionCalls[0]; + expect(call.callArguments).toEqual({ sceneName: 'Menu' }); + }); + + it('falls back to empty arguments on malformed JSON', () => { + const message = parseOne('{not valid json'); + expect(message.functionCalls[0].callArguments).toEqual({}); + }); + + it('rejects non-object arguments (array, scalar)', () => { + const arrayMessage = parseOne('["not","an","object"]'); + expect(arrayMessage.functionCalls[0].callArguments).toEqual({}); + + const scalarMessage = parseOne('42'); + expect(scalarMessage.functionCalls[0].callArguments).toEqual({}); + }); + }); + + describe('per-tool schema validation', () => { + it('accepts arguments matching the tool schema', () => { + const validation = validateToolCallArguments('create_scene', { + scene_name: 'Menu', + }); + expect(validation.valid).toBe(true); + expect(validation.errors).toEqual([]); + }); + + it('flags missing required arguments', () => { + const validation = validateToolCallArguments('create_scene', {}); + expect(validation.valid).toBe(false); + expect(validation.errors[0]).toContain('scene_name'); + }); + + it('flags wrong argument types and unknown arguments', () => { + const wrongType = validateToolCallArguments('create_scene', { + scene_name: 42, + }); + expect(wrongType.valid).toBe(false); + expect(wrongType.errors[0]).toContain("should be of type 'string'"); + + const unknown = validateToolCallArguments('create_scene', { + scene_name: 'Menu', + not_in_schema: true, + }); + expect(unknown.valid).toBe(false); + expect(unknown.errors[0]).toContain('not_in_schema'); + }); + + it('passes unknown tools through unvalidated', () => { + const validation = validateToolCallArguments('not_a_real_tool', { + anything: 'goes', + }); + expect(validation.valid).toBe(true); + }); + + it('accepts run_script with js_code (matching the editor implementation)', () => { + const validation = validateToolCallArguments('run_script', { + js_code: 'return 1;', + }); + expect(validation.valid).toBe(true); + expect(validation.errors).toEqual([]); + }); + + it('accepts run_script with the legacy script alias via parseAssistantMessage', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const message = parseAssistantMessage({ + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call-alias', + function: { + name: 'run_script', + arguments: JSON.stringify({ script: 'return 1;' }), + }, + }, + ], + }); + expect(message.functionCalls[0].callArguments).toEqual({ + script: 'return 1;', + js_code: 'return 1;', + }); + expect(warn).not.toHaveBeenCalledWith( + expect.stringContaining('failed schema validation') + ); + warn.mockRestore(); + }); + + it('keeps Ollama-style reasoning field as thinking', () => { + const message = parseAssistantMessage({ + role: 'assistant', + content: 'Hello', + reasoning: 'Let me think about TileMap.', + }); + const reasoningEntry = (message.content || []).find( + entry => entry.type === 'reasoning' + ); + expect(reasoningEntry && reasoningEntry.summary.text).toBe( + 'Let me think about TileMap.' + ); + }); + + it('treats a non-string network content as empty instead of throwing', () => { + // A proxy may send content as an object or an array of blocks (both + // truthy): the old `openAiMessage.content || ''` passed it into + // extractThinkingAndContent, whose `.match` then threw out of the turn. + expect(() => + parseAssistantMessage({ + role: 'assistant', + content: ({ text: 'hi' }: any), + }) + ).not.toThrow(); + expect(() => + parseAssistantMessage({ + role: 'assistant', + content: ([{ type: 'text', text: 'hi' }]: any), + }) + ).not.toThrow(); + }); + + it('ignores a non-string reasoning field instead of rendering an object', () => { + // summary.text is rendered as chat text: an object there throws + // 'Objects are not valid as a React child' on the next render. + const message = parseAssistantMessage({ + role: 'assistant', + content: 'Hello', + reasoning: (({ text: 'deep thought' }: any): string), + }); + const reasoningEntry = (message.content || []).find( + entry => entry.type === 'reasoning' + ); + expect(reasoningEntry).toBeUndefined(); + }); + + it('sanitizes schema-invalid tool calls in parseAssistantMessage', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const message = parseAssistantMessage({ + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call-schema', + function: { name: 'create_scene', arguments: '{}' }, + }, + ], + }); + expect(message.functionCalls[0].callArguments).toEqual({}); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('failed schema validation') + ); + warn.mockRestore(); + }); + }); + + describe('token budget helpers', () => { + it('estimates ASCII tokens at ~4 characters per token', () => { + expect(estimateTokens(null)).toBe(0); + expect(estimateTokens(undefined)).toBe(0); + expect(estimateTokens('')).toBe(0); + expect(estimateTokens('abcd')).toBe(1); + expect(estimateTokens('abcde')).toBe(2); + expect(estimateTokens('a'.repeat(400))).toBe(100); + }); + + it('counts non-Latin characters far more densely than ASCII', () => { + // CJK is roughly one token per character. Counting it at 1-per-4 (the + // old flat rule) under-reported a Chinese project structure by ~4x, so + // the trimmed prompt still exceeded the window it was trimmed for. + const cjk = '创建一个名为英雄的精灵对象并设置生命值'; // 18 chars + expect(estimateTokens(cjk)).toBe(cjk.length); + // Much denser than the ASCII rule would have produced. + expect(estimateTokens(cjk)).toBeGreaterThan( + Math.ceil(cjk.length / 4) * 3 + ); + }); + + it('counts the non-Latin part densely and the ASCII part at 1-per-4', () => { + // Mixed content: the ASCII names in a project JSON stay cheap while the + // translated labels cost their real weight. + const mixed = 'abcd英雄'; // 4 ascii + 2 cjk + expect(estimateTokens(mixed)).toBe(1 + 2); + }); + + it('keeps accented Latin text cheap (they are single-byte-ish letters)', () => { + // Guard against over-counting: é is non-ASCII but still ~1 token per + // few characters, far from CJK density. + const accented = 'Café'.repeat(20); // 80 chars, 20 non-ascii + const estimate = estimateTokens(accented); + expect(estimate).toBeLessThan(accented.length); + expect(estimate).toBeGreaterThanOrEqual(Math.ceil(accented.length / 4)); + }); + + it('scales the budget by model family and reserves output room', () => { + expect(getTokenBudget({ model: 'gpt-4o' })).toBe(64000); + expect(getTokenBudget({ model: 'claude-3-5-sonnet' })).toBe(64000); + expect(getTokenBudget({ model: 'llama3.2' })).toBe(4096); + expect(getTokenBudget({ model: 'mistral-7b' })).toBe(16384); + expect(getTokenBudget({ model: 'unknown-model' })).toBe(64000); + expect(getTokenBudget(null)).toBe(64000); + }); + + it('trims oldest history but keeps system prompt and latest exchange', () => { + const system = { role: 'system', content: 'x'.repeat(400) }; // 100 tokens + const old = Array.from({ length: 10 }, (_, i) => ({ + role: 'user', + content: 'y'.repeat(400), // 100 tokens each + })); + const last = [ + { role: 'user', content: 'z'.repeat(400) }, + { role: 'assistant', content: 'w'.repeat(400) }, + ]; + const messages = [system, ...old, ...last]; + + // Budget fits system(100) + last2(200) + 2 old(200): 500 tokens. + const trimmed = trimMessagesToBudget(messages, 500); + expect(trimmed[0]).toBe(system); + expect(trimmed).toContain(last[0]); + expect(trimmed).toContain(last[1]); + expect(trimmed.length).toBeLessThan(messages.length); + expect(estimateMessagesTokens(trimmed)).toBeLessThanOrEqual(500); + + // A malformed tool_calls field must not break the estimate: `|| []` kept + // a truthy non-array, which is not iterable. + expect(() => + estimateMessagesTokens([ + ({ + role: 'assistant', + content: 'x', + tool_calls: { not: 'an array' }, + }: any), + ({ role: 'assistant', content: 'y', tool_calls: 3 }: any), + ]) + ).not.toThrow(); + }); + + it('does not split a surrogate pair when capping the system prompt', () => { + // Astral characters (emoji, rare CJK) are two UTF-16 code units; cutting + // between them leaves a lone surrogate, which encodes to U+FFFD — the + // truncated prompt reached the model with a replacement character where a + // real scene/object name was. + const hasLoneSurrogate = (value: string): boolean => { + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(i + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) return true; + i++; + } else if (code >= 0xdc00 && code <= 0xdfff) { + return true; + } + } + return false; + }; + + // maxSystemTokens is floored at 128 and 256 tokens are reserved for the + // newest exchange, so the body must genuinely exceed the cap. Repeating + // 'A' puts a high surrogate at every odd code-unit offset, so + // roughly half of all cut points fall between the halves of a pair — + // the sweep below lands on many of them without the fix. + const body = 'A\u{1F3AE}'.repeat(2000); + const messages = [ + { role: 'system', content: body }, + { role: 'user', content: 'hi' }, + ]; + + let sawTruncation = false; + for (let budget = 300; budget <= 700; budget += 7) { + const [system] = compactSystemMessageToBudget(messages, budget); + const content = system.content; + if (typeof content !== 'string') continue; + if (content !== body) sawTruncation = true; + expect(hasLoneSurrogate(content)).toBe(false); + } + // Guard the premise: the sweep really did truncate. + expect(sawTruncation).toBe(true); + }); + + it('still returns the original system prompt when it already fits', () => { + const body = 'AAAA \u{1F3AE} BBBB'; + const messages = [ + { role: 'system', content: body }, + { role: 'user', content: 'hi' }, + ]; + const [system] = compactSystemMessageToBudget(messages, 4096); + expect(system.content).toBe(body); + }); + + it('compacts large tool outputs in place before dropping anything', () => { + const system = { role: 'system', content: 'sys' }; + const assistantWithCalls = { + role: 'assistant', + content: '', + tool_calls: [ + { id: 'c1', function: { name: 'test_tool', arguments: '{}' } }, + ], + }; + const toolOutput = { + role: 'tool', + tool_call_id: 'c1', + content: 'o'.repeat(20000), // ~5000 tokens + }; + const last = [ + { role: 'user', content: 'u'.repeat(400) }, + { role: 'assistant', content: 'w'.repeat(400) }, + ]; + const messages = [system, assistantWithCalls, toolOutput, ...last]; + + // Budget forces compaction but not dropping: compacted output + // (~500 chars + marker ≈ 140 tokens) + system + last2 < 600. + const trimmed = trimMessagesToBudget(messages, 600); + const compactedTool = trimmed.find(m => m.role === 'tool'); + expect(compactedTool).toBeDefined(); + expect(compactedTool.content).toContain('trimmed'); + expect(compactedTool.content.length).toBeLessThan(700); + // The tool_calls assistant stays paired with its output. + expect( + trimmed.find(m => m.role === 'assistant' && m.tool_calls) + ).toBeDefined(); + expect(estimateMessagesTokens(trimmed)).toBeLessThanOrEqual(600); + // The original message objects are not mutated. + expect(toolOutput.content.length).toBe(20000); + }); + + it('compacts long assistant messages too, not only tool outputs', () => { + const system = { role: 'system', content: 'sys' }; + const longAssistant = { + role: 'assistant', + content: 'a'.repeat(20000), + }; + const last = [ + { role: 'user', content: 'u'.repeat(400) }, + { role: 'assistant', content: 'w'.repeat(400) }, + ]; + const messages = [system, longAssistant, ...last]; + + const trimmed = trimMessagesToBudget(messages, 600); + const compacted = trimmed.find( + m => m.role === 'assistant' && m !== last[1] + ); + expect(compacted).toBeDefined(); + expect(compacted.content).toContain('trimmed'); + expect(estimateMessagesTokens(trimmed)).toBeLessThanOrEqual(600); + expect(longAssistant.content.length).toBe(20000); + }); + + it('respects the budget exactly once tool outputs are accounted for', () => { + // Regression: tokens of tool outputs dropped via the pairing flag were + // not subtracted from the estimate, inflating it and over-dropping + // subsequent history. + const system = { role: 'system', content: 'x'.repeat(400) }; // 100 tokens + const messages = [system]; + for (let i = 0; i < 30; i++) { + messages.push({ + role: 'assistant', + content: '', + tool_calls: [ + { id: `c${i}`, function: { name: 't', arguments: '{}' } }, + ], + }); + messages.push({ + role: 'tool', + tool_call_id: `c${i}`, + content: 'o'.repeat(4000), // 1000 tokens each + }); + } + messages.push({ role: 'user', content: 'u'.repeat(400) }); // 100 + messages.push({ role: 'assistant', content: 'w'.repeat(400) }); // 100 + + // Budget fits system(100) + last2(200) + 3 tool pairs (3×~1001) ≈ 3300. + const trimmed = trimMessagesToBudget(messages, 3303); + expect(estimateMessagesTokens(trimmed)).toBeLessThanOrEqual(3303); + // Nothing beyond the tool pairs was over-dropped. + const keptPairs = trimmed.filter(m => m.role === 'tool').length; + expect(keptPairs).toBeGreaterThanOrEqual(2); + expect(trimmed[trimmed.length - 2].content.length).toBe(400); + expect(trimmed[trimmed.length - 1].content.length).toBe(400); + }); + + it('drops tool outputs together with their tool_calls assistant', () => { + const system = { role: 'system', content: 'sys' }; + const assistantWithCalls = { + role: 'assistant', + content: 'a'.repeat(4000), + tool_calls: [{ id: 'c1', function: { name: 'f', arguments: '{}' } }], + }; + const toolOutput = { + role: 'tool', + tool_call_id: 'c1', + content: 't'.repeat(4000), + }; + const last = [ + { role: 'user', content: 'u'.repeat(400) }, + { role: 'assistant', content: 'w'.repeat(400) }, + ]; + const messages = [system, assistantWithCalls, toolOutput, ...last]; + + const trimmed = trimMessagesToBudget(messages, 300); + expect(trimmed).not.toContain(assistantWithCalls); + expect(trimmed).not.toContain(toolOutput); + // No orphan tool messages remain. + expect(trimmed.filter(m => m.role === 'tool')).toEqual([]); + expect(trimmed[0]).toBe(system); + }); + + it('compacts an oversized system project structure so create fits a small local budget', () => { + const guidelines = 'You are GDevelop AI Assistant.\n'; + const structure = + 'Current Project Structure:\n' + + JSON.stringify({ + scenes: Array.from({ length: 500 }, (_, i) => ({ + name: 'Scene' + i, + events: 'x'.repeat(200), + })), + }); + const extensions = + '\nInstalled Project Extensions:\n' + 'ext'.repeat(200) + '\n'; + const system = { + role: 'system', + content: guidelines + structure + extensions, + }; + const user = { role: 'user', content: 'hello' }; + // llama budget is 4096; system alone is far larger. + expect(estimateTokens(system.content)).toBeGreaterThan(4096); + + const trimmed = trimMessagesToBudget([system, user], 4096); + expect(trimmed).toHaveLength(2); + expect(trimmed[1]).toBe(user); + const sys = trimmed[0]; + expect(sys.role).toBe('system'); + expect(sys.content).toContain('You are GDevelop AI Assistant'); + expect(sys.content).toContain( + 'truncated to fit the model context window' + ); + // Guidelines head preserved; structure body shortened. + expect(sys.content.indexOf('Current Project Structure:')).toBeGreaterThan( + -1 + ); + expect(estimateMessagesTokens(trimmed)).toBeLessThanOrEqual(4096); + expect(estimateTokens(sys.content)).toBeLessThanOrEqual(4096 - 256); + }); + + it('leaves a system prompt that already fits the budget untouched (same reference)', () => { + const system = { role: 'system', content: 'short guidelines' }; + const user = { role: 'user', content: 'hi' }; + const messages = [system, user]; + const trimmed = trimMessagesToBudget(messages, 4096); + expect(trimmed[0]).toBe(system); + expect(trimmed).toBe(messages); + }); + }); + + describe('normalizeBaseUrl tolerates non-string input', () => { + it('falls back to the default instead of throwing', () => { + // Preferences hydrate each stored key without type-checking it, so a + // corrupted `aiCustomBaseUrl` (a number, null, an object) reached this + // function through testConnection and threw `trim is not a function` + // before any request was made. + for (const bad of [123, null, undefined, {}, [], true]) { + // $FlowFixMe deliberately passing wrong types. + expect(normalizeBaseUrl(bad)).toBe('http://localhost:11434/v1'); + } + }); + + it('still normalizes real strings', () => { + expect(normalizeBaseUrl('localhost:11434/v1')).toBe( + 'http://localhost:11434/v1' + ); + expect(normalizeBaseUrl(' example.com/v1 ')).toBe( + 'https://example.com/v1' + ); + }); + }); + + describe('wire temperature bound', () => { + it('clamps an out-of-range temperature on the bypass path', async () => { + // sendChatCompletion accepts a raw config, and testConnection merges a + // caller-supplied partial over the stored one, so sanitizeCustomAIConfig + // is not always in the path. An unclamped 42 (or a NaN that serializes + // to null) reached the provider. + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + + await sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: { + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'llama3.2', + temperature: 42, + }, + }); + + expect(axios.post.mock.calls[0][1].temperature).toBe(1); + }); + + it('fails with an explanatory error when the request rejects with no reason', async () => { + // A rejection with no reason (undefined/null) must not throw a second + // TypeError out of the error handler, masking the real failure - and + // testConnection below must still return a failure object, not throw. + // $FlowFixMe + axios.post.mockRejectedValueOnce(undefined); + + await expect( + sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: { + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'llama3.2', + temperature: 0.7, + }, + }) + ).rejects.toThrow('AI request failed'); + }); + + it('replaces a non-finite temperature with the default', async () => { + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + + await sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: { + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'llama3.2', + temperature: NaN, + }, + }); + + // Not null: JSON.stringify(NaN) is `null`, which strict servers reject. + expect(axios.post.mock.calls[0][1].temperature).toBe(0.7); + }); + }); + + describe('request timeout bound', () => { + it('never passes an overflowing timeout to axios', async () => { + // sendChatCompletion takes a raw `config`, bypassing + // sanitizeCustomAIConfig — that is the path the request-time clamp + // exists for. Without it the oversized delay reaches setTimeout/axios, + // which clamp it to 1 ms and abort the request immediately. + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + + await sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: { + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'llama3.2', + temperature: 0.7, + timeoutMs: 3000000000, + }, + }); + + expect(axios.post).toHaveBeenCalledTimes(1); + const axiosOptions = axios.post.mock.calls[0][2] || {}; + expect(axiosOptions.timeout).toBe(MAX_TIMEOUT_MS); + // Guard the premise: the raw value really would overflow. + expect(3000000000).toBeGreaterThan(MAX_TIMEOUT_MS); + }); + }); + + describe('local-first request headers', () => { + it('does not send attribution headers that break local CORS preflight', async () => { + // Live finding (cycle 189): Ollama's preflight rejects `x-title` + // (not in Access-Control-Allow-Headers), so the browser build could not + // talk to a local OpenAI-compatible server at all. HTTP-Referer/X-Title + // are OpenRouter attribution extras, optional and useless locally. + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + + await sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: { + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'llama3.2', + temperature: 0.7, + }, + }); + + const headers = axios.post.mock.calls[0][2].headers; + expect(headers['X-Title']).toBeUndefined(); + expect(headers['HTTP-Referer']).toBeUndefined(); + // The essential header is still sent. + expect(headers['Content-Type']).toBe('application/json'); + }); + }); + + describe('buildSystemPrompt', () => { + it('uses the shared prompt without a role, and the role prompt with one', () => { + const base = buildSystemPrompt({}); + expect(base).toContain('GDevelop AI Assistant'); + expect(base).not.toContain('the developer of a small game studio'); + + const developer = buildSystemPrompt({ role: 'developer' }); + expect(developer).toContain('the developer of a small game studio'); + expect(developer).toContain('GDevelop AI Assistant'); + }); + + it('falls back to the shared prompt for an unrecognized persisted role', () => { + // `role` is read back from a persisted AiRequest and is not validated; + // an unknown id must not throw while the turn's prompt is built. + let prompt; + expect(() => { + prompt = buildSystemPrompt({ role: 'legacy-role' }); + }).not.toThrow(); + expect(prompt).toContain('GDevelop AI Assistant'); + expect(prompt).not.toContain('the developer of a small game studio'); + }); + + it('appends the project structure, extensions and spawn context when given', () => { + const prompt = buildSystemPrompt({ + gameProjectJson: '{"objects":[]}', + projectSpecificExtensionsSummaryJson: '{"ext":1}', + spawnContextNote: 'GDD note', + }); + expect(prompt).toContain('Current Project Structure:'); + expect(prompt).toContain('{"objects":[]}'); + expect(prompt).toContain('Installed Project Extensions:'); + expect(prompt).toContain('GDD note'); + }); + }); + + describe('getEffectiveConfigForRequest', () => { + it('returns the global config, then the per-chat override, then back', () => { + setCustomEndpointConfig({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'qwen2.5-coder', + temperature: 0.7, + }); + expect(getEffectiveConfigForRequest('local-ai-x').model).toBe( + 'qwen2.5-coder' + ); + + customSetAiRequestModelOverride('local-ai-x', 'deepseek-chat'); + expect(getEffectiveConfigForRequest('local-ai-x').model).toBe( + 'deepseek-chat' + ); + // Another chat is unaffected. + expect(getEffectiveConfigForRequest('local-ai-y').model).toBe( + 'qwen2.5-coder' + ); + + customSetAiRequestModelOverride('local-ai-x', ''); + expect(getEffectiveConfigForRequest('local-ai-x').model).toBe( + 'qwen2.5-coder' + ); + }); + }); + + describe('provider telemetry logging', () => { + it('formats routing/proxy telemetry headers into one line', () => { + expect( + formatProviderTelemetry({ + 'x-omniroute-model': 'deepseek-v4.1-flash', + 'x-omniroute-latency-ms': '3361', + 'x-omniroute-tokens-in': '8856', + 'x-omniroute-tokens-out': '90', + 'x-omniroute-cache': 'MISS', + }) + ).toBe( + 'model=deepseek-v4.1-flash latencyMs=3361 tokens=8856in/90out cache=MISS' + ); + }); + + it('returns null when no telemetry headers are present', () => { + expect(formatProviderTelemetry(undefined)).toBeNull(); + expect(formatProviderTelemetry(null)).toBeNull(); + expect(formatProviderTelemetry({})).toBeNull(); + expect( + formatProviderTelemetry({ 'content-type': 'application/json' }) + ).toBeNull(); + }); + + it('logs per-turn telemetry to the dev console when the provider sends it', async () => { + const debugSpy = jest + .spyOn(console, 'debug') + .mockImplementation(() => {}); + try { + axios.post.mockResolvedValueOnce({ + status: 200, + headers: { + 'x-omniroute-model': 'deepseek-v4.1-flash', + 'x-omniroute-latency-ms': '3361', + 'x-omniroute-tokens-in': '8856', + 'x-omniroute-tokens-out': '90', + }, + data: { + choices: [{ message: { role: 'assistant', content: 'ok' } }], + }, + }); + + await sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: { + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'llama3.2', + temperature: 0.7, + }, + }); + + expect(debugSpy).toHaveBeenCalledTimes(1); + expect(debugSpy.mock.calls[0][0]).toContain('[BYOK]'); + expect(debugSpy.mock.calls[0][0]).toContain('latencyMs=3361'); + expect(debugSpy.mock.calls[0][0]).toContain('tokens=8856in/90out'); + } finally { + debugSpy.mockRestore(); + } + }); + + it('stays quiet when the provider sends no telemetry headers', async () => { + const debugSpy = jest + .spyOn(console, 'debug') + .mockImplementation(() => {}); + try { + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [{ message: { role: 'assistant', content: 'ok' } }], + }, + }); + + await sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: { + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'llama3.2', + temperature: 0.7, + }, + }); + + expect(debugSpy).not.toHaveBeenCalled(); + } finally { + debugSpy.mockRestore(); + } + }); + + it("clears a request's telemetry when it is deleted", async () => { + // Every other per-request registry is cleared on delete; telemetry was + // missed, so a deleted chat left its entry behind forever. + setCustomEndpointConfig({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'llama3.2', + temperature: 0.7, + }); + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + headers: { 'x-omniroute-latency-ms': '55' }, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + const created = await customCreateAiRequest({ + userRequest: 'telemetry cleanup', + mode: 'chat', + }); + expect(customGetAiRequestProviderTelemetry(created.id)).toEqual( + expect.objectContaining({ latencyMs: 55 }) + ); + + customDeleteAiRequest(created.id); + expect(customGetAiRequestProviderTelemetry(created.id)).toBeNull(); + }); + + it('parses telemetry into numbers and stores it per request', async () => { + // The auditing data layer: the console line existed but nothing was + // inspectable from React state. parseProviderTelemetry is stored against + // the chat so a dashboard can read it. + expect( + parseProviderTelemetry({ + 'x-omniroute-model': 'deepseek-v4.1-flash', + 'x-omniroute-latency-ms': '3361', + 'x-omniroute-tokens-in': '8856', + 'x-omniroute-tokens-out': '90', + 'x-omniroute-cache': 'MISS', + }) + ).toEqual({ + model: 'deepseek-v4.1-flash', + latencyMs: 3361, + tokensIn: 8856, + tokensOut: 90, + cache: 'MISS', + }); + expect(parseProviderTelemetry({})).toBeNull(); + // A non-numeric latency is dropped, not stored as NaN. + expect( + parseProviderTelemetry({ 'x-omniroute-latency-ms': 'soon' }) + ).toBeNull(); + + setCustomEndpointConfig({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'llama3.2', + temperature: 0.7, + }); + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + headers: { + 'x-omniroute-model': 'llama3.2', + 'x-omniroute-latency-ms': '120', + 'x-omniroute-tokens-in': '30', + 'x-omniroute-tokens-out': '7', + }, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + + const created = await customCreateAiRequest({ + userRequest: 'telemetry probe', + mode: 'chat', + }); + const stored = customGetAiRequestProviderTelemetry(created.id); + expect(stored && stored.model).toBe('llama3.2'); + expect(stored && stored.latencyMs).toBe(120); + expect(stored && stored.tokensIn).toBe(30); + expect(typeof stored.at).toBe('string'); + }); + + it('reads headers exposed through .get (fetch Headers are index-opaque)', () => { + // fetch Headers expose nothing by index and AxiosHeaders.get is + // case-insensitive while index access is not: reading `headers[name]` + // saw no telemetry on either in production-shaped responses. The fake + // below is index-opaque like a real fetch Headers (only .get reads). + const store = { + 'x-omniroute-model': 'qwen2.5-coder', + 'x-omniroute-latency-ms': '812', + }; + const headers = { + get: (name: string): ?string => store[name.toLowerCase()] || null, + }; + expect(formatProviderTelemetry(headers)).toBe( + 'model=qwen2.5-coder latencyMs=812' + ); + }); + + it('logs per-turn telemetry for streamed turns too', async () => { + // The streaming path never logged: a session with streaming on (the + // local-model default) emitted no per-turn observability at all. + const debugSpy = jest + .spyOn(console, 'debug') + .mockImplementation(() => {}); + const encoder = new TextEncoder(); + const body = + 'data: ' + + JSON.stringify({ choices: [{ delta: { content: 'hi' } }] }) + + '\n'; + const reader = { + read: (() => { + let done = false; + return async () => { + if (done) return { done: true, value: undefined }; + done = true; + return { done: false, value: encoder.encode(body) }; + }; + })(), + }; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + // Index-opaque like a real fetch Headers: only .get reads. + headers: { + get: (name: string): ?string => + ({ + 'content-type': 'text/event-stream', + 'x-omniroute-latency-ms': '812', + 'x-omniroute-tokens-in': '120', + 'x-omniroute-tokens-out': '3', + }[name.toLowerCase()] || null), + }, + body: { getReader: () => reader }, + }); + try { + await sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: { + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'qwen2.5-coder', + temperature: 0.7, + streaming: true, + }, + }); + + expect(debugSpy).toHaveBeenCalledTimes(1); + expect(debugSpy.mock.calls[0][0]).toContain('[BYOK]'); + expect(debugSpy.mock.calls[0][0]).toContain('latencyMs=812'); + expect(debugSpy.mock.calls[0][0]).toContain('tokens=120in/3out'); + } finally { + debugSpy.mockRestore(); + } + }); + }); + + describe('backend-only tools are withheld from local turns', () => { + it('drops backend-resolved tools while keeping the rest', () => { + const tools = withoutBackendOnlyTools(GDEVELOP_OPENAI_TOOLS); + const names = tools.map(tool => tool.function.name); + expect(names).not.toContain('get_game_starter_summary'); + // Everything else is still offered: the filter removes one entry. + expect(names).toHaveLength(GDEVELOP_OPENAI_TOOLS.length - 1); + expect(names).toContain('create_scene'); + }); + + it('retries the first turn at a smaller budget on overflow', async () => { + setCustomEndpointConfig({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'llama3.2', + temperature: 0.7, + }); + // $FlowFixMe + axios.post.mockRejectedValueOnce({ + response: { + status: 400, + data: { + error: { + message: + "this model's maximum context length is 4096 tokens, however you requested 8000 tokens (the input is too large)", + }, + }, + }, + }); + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + const hugeProject = JSON.stringify({ + scenes: Array.from({ length: 800 }, (_, i) => ({ + name: 'Scene' + i, + events: Array.from({ length: 20 }, () => ({ code: 'y'.repeat(150) })), + })), + }); + const created = await customCreateAiRequest({ + userRequest: 'start', + gameProjectJson: hugeProject, + mode: 'agent', + }); + expect(created.status).not.toBe('error'); + expect(axios.post).toHaveBeenCalledTimes(2); + const first = estimateMessagesTokens( + axios.post.mock.calls[0][1].messages + ); + const second = estimateMessagesTokens( + axios.post.mock.calls[1][1].messages + ); + expect(second).toBeLessThan(first); + }); + + it('records a sub-agent turn against the parent AND the child', async () => { + // Cost is billed to the parent chat (the UI meter) and also to the + // sub-agent's own id, so per-agent usage is auditable. + setCustomEndpointConfig({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'llama3.2', + temperature: 0.7, + }); + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + const child = await customCreateSubAgentAiRequest({ + parentAiRequestId: 'local-ai-parent-x', + roleId: 'developer', + userRequest: 'build it', + gameProjectJson: null, + projectSpecificExtensionsSummaryJson: null, + spawnContextNote: null, + }); + expect(customGetAiRequestTokenTotal(child.id)).toBeGreaterThan(0); + expect(customGetAiRequestTokenTotal('local-ai-parent-x')).toBeGreaterThan( + 0 + ); + }); + + it('retries a sub-agent turn at a smaller budget on overflow', async () => { + setCustomEndpointConfig({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'llama3.2', + temperature: 0.7, + }); + // $FlowFixMe + axios.post.mockRejectedValueOnce({ + response: { + status: 400, + data: { + error: { + message: + "this model's maximum context length is 4096 tokens, however you requested 8000 tokens (the input is too large)", + }, + }, + }, + }); + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + const hugeProject = JSON.stringify({ + scenes: Array.from({ length: 800 }, (_, i) => ({ + name: 'Scene' + i, + events: Array.from({ length: 20 }, () => ({ code: 'y'.repeat(150) })), + })), + }); + const child = await customCreateSubAgentAiRequest({ + parentAiRequestId: 'local-ai-parent', + roleId: 'developer', + userRequest: 'build it', + gameProjectJson: hugeProject, + projectSpecificExtensionsSummaryJson: null, + spawnContextNote: null, + }); + expect(child.id).toBeTruthy(); + expect(axios.post).toHaveBeenCalledTimes(2); + const first = estimateMessagesTokens( + axios.post.mock.calls[0][1].messages + ); + const second = estimateMessagesTokens( + axios.post.mock.calls[1][1].messages + ); + expect(second).toBeLessThan(first); + }); + + it('retries once when the endpoint is unreachable', async () => { + setCustomEndpointConfig({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'llama3.2', + temperature: 0.7, + }); + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + const created = await customCreateAiRequest({ + userRequest: 'seed', + mode: 'chat', + }); + axios.post.mockClear(); + + // $FlowFixMe reject once with a transport error (an Error, as axios + // rejects, so its code survives the client's non-Error normalization). + const refused = new Error('connect ECONNREFUSED 127.0.0.1:11434'); + // $FlowFixMe + refused.code = 'ECONNREFUSED'; + axios.post.mockRejectedValueOnce(refused); + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + + const result = await customAddMessageToAiRequest({ + aiRequestId: created.id, + userMessage: 'next', + }); + expect(result.status).not.toBe('error'); + expect(axios.post).toHaveBeenCalledTimes(2); + + // The predicate: transport-level only. + expect(isNetworkLevelError({ code: 'ECONNREFUSED' })).toBe(true); + expect(isNetworkLevelError({ message: 'Network Error' })).toBe(true); + expect(isNetworkLevelError({ response: { status: 500 } })).toBe(false); + expect(isNetworkLevelError(null)).toBe(false); + expect(isNetworkLevelError({ message: 'shuffle failure' })).toBe(false); + }); + + it('retries a transient provider 503 and succeeds', async () => { + // feedback-loop Run 6: a 503 ("endpoint unavailable") killed a developer + // sub-agent's turn outright. A transient server error must be retried. + setCustomEndpointConfig({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'llama3.2', + temperature: 0.7, + }); + // $FlowFixMe + axios.post.mockRejectedValueOnce({ + response: { status: 503, data: { error: { message: 'unavailable' } } }, + }); + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + const result = await customCreateAiRequest({ + userRequest: 'start', + mode: 'chat', + }); + expect(result.status).not.toBe('error'); + expect(axios.post).toHaveBeenCalledTimes(2); + }); + + it('falls back to the secondary model when the primary keeps failing', async () => { + // Tier 2 fallback routing: after the provider retries are exhausted, a + // configured fallback model gets one more attempt. + _setProviderRetryDelayForTesting(0); + setCustomEndpointConfig({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'primary-model', + fallbackModel: 'fallback-model', + temperature: 0.7, + }); + for (let i = 0; i < 3; i++) { + // $FlowFixMe + axios.post.mockRejectedValueOnce({ + response: { + status: 503, + data: { error: { message: 'unavailable' } }, + }, + }); + } + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + const result = await customCreateAiRequest({ + userRequest: 'start', + mode: 'chat', + }); + expect(result.status).not.toBe('error'); + // initial + 2 retries + 1 fallback. + expect(axios.post).toHaveBeenCalledTimes(4); + expect(axios.post.mock.calls[3][1].model).toBe('fallback-model'); + }); + + it('does not retry a real client error (400)', async () => { + setCustomEndpointConfig({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'llama3.2', + temperature: 0.7, + }); + // $FlowFixMe + axios.post.mockRejectedValue({ + response: { status: 400, data: { error: { message: 'bad request' } } }, + }); + const result = await customCreateAiRequest({ + userRequest: 'start', + mode: 'chat', + }); + expect(result.status).toBe('error'); + expect(axios.post).toHaveBeenCalledTimes(1); + }); + + it('classifies which provider errors are retryable', () => { + const providerError = (status: number) => { + const error = new Error('provider'); + // $FlowFixMe: the client attaches the status for exactly this. + (error: any).providerStatus = status; + return error; + }; + expect(getProviderErrorStatus(providerError(503))).toBe(503); + expect(getProviderErrorStatus(new Error('x'))).toBeNull(); + expect(isRetryableProviderError(providerError(429))).toBe(true); + expect(isRetryableProviderError(providerError(408))).toBe(true); + expect(isRetryableProviderError(providerError(502))).toBe(true); + expect(isRetryableProviderError(providerError(503))).toBe(true); + expect(isRetryableProviderError(providerError(504))).toBe(true); + // 500 is a genuine server error and is left to the caller (see the + // existing "no 500 retry" behaviour), as are 4xx client errors. + expect(isRetryableProviderError(providerError(500))).toBe(false); + expect(isRetryableProviderError(providerError(400))).toBe(false); + expect(isRetryableProviderError(providerError(401))).toBe(false); + expect(isRetryableProviderError(new Error('network'))).toBe(false); + }); + + it('retries once with a smaller prompt when the window overflows', async () => { + setCustomEndpointConfig({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'llama3.2', + temperature: 0.7, + }); + // Seed a long history: old messages are what a tighter trim can drop. + // The newest message is protected, so it must not be the overflow + // source. + const messages = []; + for (let i = 0; i < 80; i++) { + messages.push({ + type: 'message', + status: 'completed', + role: i % 2 === 0 ? 'user' : 'assistant', + content: [ + i % 2 === 0 + ? { + type: 'user_request', + status: 'completed', + text: `turn ${i} ` + 'x'.repeat(200), + } + : { + type: 'output_text', + status: 'completed', + text: 'ok ' + 'y'.repeat(200), + annotations: [], + }, + ], + messageId: `m-${i}`, + }); + } + customUpdateAiRequest( + ({ + id: 'local-ai-overflow', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + userId: LOCAL_BYOK_USER_ID, + status: 'ready', + error: null, + output: messages, + }: any) + ); + + // $FlowFixMe + axios.post.mockRejectedValueOnce({ + response: { + status: 400, + data: { + error: { + message: + "this model's maximum context length is 4096 tokens, however you requested 9000 tokens (the input is too large)", + }, + }, + }, + }); + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + + const result = await customAddMessageToAiRequest({ + aiRequestId: 'local-ai-overflow', + userMessage: 'next', + }); + + expect(result.status).not.toBe('error'); + expect(axios.post).toHaveBeenCalledTimes(2); + const first = estimateMessagesTokens( + axios.post.mock.calls[0][1].messages + ); + const second = estimateMessagesTokens( + axios.post.mock.calls[1][1].messages + ); + expect(second).toBeLessThan(first); + expect(second).toBeLessThanOrEqual(2048 + 64); + }); + + it('does not retry a non-overflow provider error', async () => { + setCustomEndpointConfig({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'llama3.2', + temperature: 0.7, + }); + // $FlowFixMe + axios.post.mockRejectedValueOnce({ + response: { + status: 500, + data: { error: { message: 'internal shuffle failure' } }, + }, + }); + const result = await customAddMessageToAiRequest({ + aiRequestId: 'local-ai-no-retry', + userMessage: 'next', + }); + expect(axios.post).toHaveBeenCalledTimes(1); + expect(result.status).toBe('error'); + expect( + isContextOverflowError({ + response: { data: { error: { message: 'maximum context length' } } }, + }) + ).toBe(true); + expect( + isContextOverflowError({ + response: { data: { error: { message: 'nope' } } }, + }) + ).toBe(false); + expect(isContextOverflowError(null)).toBe(false); + }); + + it('does not offer backend-only tools on later local turns or sub-agents', async () => { + // The create path had a test (cycle 169) but the continue and sub-agent + // paths were only covered by the pure filter - a regression that dropped + // the filter there would silently re-offer the always-failing tool. + setCustomEndpointConfig({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'llama3.2', + temperature: 0.7, + }); + // $FlowFixMe + axios.post.mockResolvedValue({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + const created = await customCreateAiRequest({ + userRequest: 'start', + mode: 'chat', + }); + axios.post.mockClear(); + await customAddMessageToAiRequest({ + aiRequestId: created.id, + userMessage: 'next', + }); + const continueTools = axios.post.mock.calls[0][1].tools.map( + (tool: any) => tool.function.name + ); + expect(continueTools).not.toContain('get_game_starter_summary'); + expect(continueTools).toContain('create_scene'); + + axios.post.mockClear(); + await customCreateSubAgentAiRequest({ + parentAiRequestId: created.id, + roleId: 'developer', + userRequest: 'build it', + gameProjectJson: null, + projectSpecificExtensionsSummaryJson: null, + spawnContextNote: null, + }); + const subAgentTools = axios.post.mock.calls[0][1].tools.map( + (tool: any) => tool.function.name + ); + expect(subAgentTools).not.toContain('get_game_starter_summary'); + }); + + it('restricts a top-level orchestrator request to the manager role', async () => { + // feedback-loop Run 5: the top-level orchestrator got no role prompt and + // every tool, so the "manager" built the scene itself instead of + // delegating. It must run with the manager prompt and tool subset. + setCustomEndpointConfig({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'llama3.2', + temperature: 0.7, + }); + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + const created = await customCreateAiRequest({ + userRequest: 'build the city', + mode: 'orchestrator', + }); + const createTools = axios.post.mock.calls[0][1].tools.map( + (tool: any) => tool.function.name + ); + expect(createTools).toContain('create_or_update_plan'); + expect(createTools).toContain('spawn_agent'); + expect(createTools).not.toContain('create_scene'); + expect(createTools).not.toContain('create_or_replace_object'); + const createSystemPrompt = axios.post.mock.calls[0][1].messages.find( + (message: any) => message.role === 'system' + ).content; + expect(createSystemPrompt).toContain('studio lead'); + + axios.post.mockClear(); + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + await customAddMessageToAiRequest({ + aiRequestId: created.id, + userMessage: 'go', + }); + const continueTools = axios.post.mock.calls[0][1].tools.map( + (tool: any) => tool.function.name + ); + expect(continueTools).toContain('spawn_agent'); + expect(continueTools).not.toContain('create_scene'); + }); + + it('names only declared tools in the backend-only allowlist', () => { + // This is an ALLOWLIST: a typo here silently stops filtering the tool + // (or filters nothing), so every member must be a declared registry name. + const declared = new Set( + GDEVELOP_OPENAI_TOOLS.map(tool => tool.function.name) + ); + expect(BACKEND_ONLY_TOOL_NAMES).toContain('get_game_starter_summary'); + const unknown = BACKEND_ONLY_TOOL_NAMES.filter( + name => !declared.has(name) + ); + expect(unknown).toEqual([]); + }); + + it('does not offer get_game_starter_summary on a local create', async () => { + // Its local launchFunction unconditionally fails ("handled on the + // backend"), so offering it burns a turn and its tokens for nothing. + setCustomEndpointConfig({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'llama3.2', + temperature: 0.7, + }); + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [{ message: { role: 'assistant', content: 'ok' } }], + }, + }); + + await customCreateAiRequest({ + userRequest: 'study a starter', + mode: 'chat', + }); + + const sentTools = axios.post.mock.calls[0][1].tools.map( + (tool: any) => tool.function.name + ); + expect(sentTools).not.toContain('get_game_starter_summary'); + expect(sentTools).toContain('create_scene'); + }); + }); + + describe('create path context budget', () => { + it('sends a budgeted system prompt when the project structure alone exceeds the model budget', async () => { + setCustomEndpointConfig({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'llama3.2', + temperature: 0.7, + }); + const hugeProject = JSON.stringify({ + scenes: Array.from({ length: 800 }, (_, i) => ({ + name: 'Scene' + i, + events: Array.from({ length: 20 }, (_, j) => ({ + type: 'Event' + j, + code: 'y'.repeat(150), + })), + })), + }); + expect(estimateTokens(hugeProject)).toBeGreaterThan(4096); + + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + + const created = await customCreateAiRequest({ + userRequest: 'start', + gameProjectJson: hugeProject, + projectSpecificExtensionsSummaryJson: null, + mode: 'chat', + }); + expect(created.status).toBe('ready'); + + expect(axios.post).toHaveBeenCalledTimes(1); + const body = axios.post.mock.calls[0][1]; + const systemMessage = (body.messages || []).find( + m => m.role === 'system' + ); + expect(systemMessage).toBeTruthy(); + expect(systemMessage.content).toContain('You are GDevelop AI Assistant'); + expect(systemMessage.content).toContain( + 'truncated to fit the model context window' + ); + // llama3.2 budget = 4096; leave exchange reserve inside compaction. + expect(estimateMessagesTokens(body.messages)).toBeLessThanOrEqual(4096); + // The structure was compacted: surface that to the chat UI. + expect(customGetAiRequestSystemCompacted(created.id)).toBe(true); + }); + }); + + describe('system structure compaction surfacing', () => { + it('leaves the flag false when the system prompt already fits the budget', async () => { + setCustomEndpointConfig({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'llama3.2', + temperature: 0.7, + }); + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + const created = await customCreateAiRequest({ + userRequest: 'start', + gameProjectJson: null, + projectSpecificExtensionsSummaryJson: null, + mode: 'chat', + }); + expect(created.status).toBe('ready'); + expect(customGetAiRequestSystemCompacted(created.id)).toBe(false); + + const body = axios.post.mock.calls[0][1]; + const systemMessage = (body.messages || []).find( + m => m.role === 'system' + ); + expect(systemMessage.content).not.toContain( + 'truncated to fit the model context window' + ); + }); + + it('records compaction against the request and clears on reset and delete', async () => { + setCustomEndpointConfig({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'llama3.2', + temperature: 0.7, + }); + const hugeProject = JSON.stringify({ + scenes: Array.from({ length: 800 }, (_, i) => ({ + name: 'Scene' + i, + events: Array.from({ length: 20 }, (_, j) => ({ + type: 'Event' + j, + code: 'y'.repeat(150), + })), + })), + }); + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + const created = await customCreateAiRequest({ + userRequest: 'start', + gameProjectJson: hugeProject, + projectSpecificExtensionsSummaryJson: null, + mode: 'chat', + }); + expect(customGetAiRequestSystemCompacted(created.id)).toBe(true); + + customDeleteAiRequest(created.id); + expect(customGetAiRequestSystemCompacted(created.id)).toBe(false); + + // Recreate with a compact system and confirm reset still clears. + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + const small = await customCreateAiRequest({ + userRequest: 'start', + gameProjectJson: null, + projectSpecificExtensionsSummaryJson: null, + mode: 'chat', + }); + expect(customGetAiRequestSystemCompacted(small.id)).toBe(false); + _resetCustomAiClientForTesting(); + expect(customGetAiRequestSystemCompacted(small.id)).toBe(false); + }); + + it('flags system compaction on a later addMessage turn when the structure grows', async () => { + setCustomEndpointConfig({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'llama3.2', + temperature: 0.7, + }); + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + const created = await customCreateAiRequest({ + userRequest: 'start', + gameProjectJson: null, + projectSpecificExtensionsSummaryJson: null, + mode: 'chat', + }); + expect(customGetAiRequestSystemCompacted(created.id)).toBe(false); + + // History-trim path does not change the system prompt → flag stays false. + const bigRequest = 'x'.repeat(100000); + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [{ message: { role: 'assistant', content: 'done' } }], + }, + }); + await customAddMessageToAiRequest({ + aiRequestId: created.id, + userMessage: bigRequest, + }); + expect(customGetAiRequestContextTrimCount(created.id)).toBeGreaterThan(0); + expect(customGetAiRequestSystemCompacted(created.id)).toBe(false); + }); + }); + + describe('context trim count surfacing', () => { + it('records trims against the parent chat and resets on reset', async () => { + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + const created = await customCreateAiRequest({ + userRequest: 'orchestrate', + }); + + // Build enough history that trimming has something to drop: a huge + // user turn (~25k estimated tokens) answered by the model. + const bigRequest = 'x'.repeat(100000); + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [{ message: { role: 'assistant', content: 'done' } }], + }, + }); + await customAddMessageToAiRequest({ + aiRequestId: created.id, + userMessage: bigRequest, + }); + + // Now the next turn replays that huge history: it must be trimmed. + axios.post.mockImplementationOnce( + (url, body, options) => + new Promise((resolve, reject) => { + options.signal.addEventListener('abort', () => + reject(new Error('canceled')) + ); + }) + ); + const pending = customAddMessageToAiRequest({ + aiRequestId: created.id, + userMessage: 'continue', + }); + await new Promise(r => setTimeout(r, 50)); + customSuspendAiRequest(created.id); + // The chat-turn path absorbs a user-initiated stop: it resolves with + // the suspended request instead of rejecting. + const stopped = await pending; + expect(stopped.status).toBe('suspended'); + + expect(customGetAiRequestContextTrimCount(created.id)).toBeGreaterThan(0); + expect(customGetAiRequestTokenTotal(created.id)).toBeGreaterThan(0); + _resetCustomAiClientForTesting(); + expect(customGetAiRequestContextTrimCount(created.id)).toBe(0); + expect(customGetAiRequestTokenTotal(created.id)).toBe(0); + }); + }); + + describe('cancelable local create', () => { + const waitFor = condition => { + return new Promise((resolve, reject) => { + const started = Date.now(); + const check = () => { + if (condition()) return resolve(); + if (Date.now() - started > 2000) + return reject(new Error('Condition not met in time')); + setTimeout(check, 10); + }; + check(); + }); + }; + + it('aborts a hung create and returns status suspended with no error', async () => { + axios.post.mockImplementationOnce( + (url, body, options) => + new Promise((resolve, reject) => { + options.signal.addEventListener('abort', () => + reject(new Error('canceled')) + ); + }) + ); + const pending = customCreateAiRequest({ + userRequest: 'hang forever', + }); + await waitFor(() => axios.post.mock.calls.length >= 1); + expect(customHasPendingCreateAiRequest()).toBe(true); + + customAbortPendingCreateAiRequests(); + const result = await pending; + expect(result.status).toBe('suspended'); + expect(result.error).toBe(null); + expect(customHasPendingCreateAiRequest()).toBe(false); + }); + + it('cancels on entry when Stop is pressed before create registers', async () => { + // No in-flight create: Stop arms the next create to cancel on entry. + customAbortPendingCreateAiRequests(); + expect(customHasPendingCreateAiRequest()).toBe(true); + + const cancelled = await customCreateAiRequest({ + userRequest: 'never sent', + }); + expect(cancelled.status).toBe('suspended'); + expect(cancelled.error).toBe(null); + expect(axios.post).not.toHaveBeenCalled(); + expect(customHasPendingCreateAiRequest()).toBe(false); + }); + + it('abort with no pending is a no-op for an already-finished create', async () => { + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + const created = await customCreateAiRequest({ userRequest: 'quick' }); + expect(created.status).toBe('ready'); + expect(customHasPendingCreateAiRequest()).toBe(false); + // Arms the early-cancel flag only (no live create to abort). + customAbortPendingCreateAiRequests(); + expect(customHasPendingCreateAiRequest()).toBe(true); + _resetCustomAiClientForTesting(); + expect(customHasPendingCreateAiRequest()).toBe(false); + }); + }); + + describe('local-server error hints', () => { + const minimalConfig = { + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'qwen2.5-coder', + temperature: 0.7, + }; + + it('hints at VRAM pressure on out-of-memory 500s', async () => { + // $FlowFixMe + axios.post.mockRejectedValueOnce({ + response: { + status: 500, + data: { + error: { + message: 'model requires more system memory than is available', + }, + }, + }, + }); + + await expect( + sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: minimalConfig, + }) + ).rejects.toThrow(/GPU\/VRAM/); + }); + + it('hints at context overflow, not VRAM, when the message says both', async () => { + // Local servers phrase context overflow with words the VRAM pattern also + // matches ("too large"). The context hint must win, or the user is told + // to shrink their quantization/VRAM for what is actually a full window. + // $FlowFixMe + axios.post.mockRejectedValueOnce({ + response: { + status: 400, + data: { + error: { + message: + "this model's maximum context length is 4096 tokens, however you requested 5000 tokens (you requested 5000; the input is too large)", + }, + }, + }, + }); + + const err = await sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: minimalConfig, + }).catch(e => e); + expect(err.message).toMatch(/context window/); + expect(err.message).not.toMatch(/GPU\/VRAM/); + }); + + it('hints at context overflow for a bare length-exceeded message', async () => { + // $FlowFixMe + axios.post.mockRejectedValueOnce({ + response: { + status: 400, + data: { + error: { message: 'the input length exceeds the context length' }, + }, + }, + }); + + await expect( + sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: minimalConfig, + }) + ).rejects.toThrow(/context window/); + }); + + it('hints at server reachability on connection refused', async () => { + // $FlowFixMe + axios.post.mockRejectedValueOnce({ + code: 'ECONNREFUSED', + message: 'connect ECONNREFUSED 127.0.0.1:11434', + }); + + await expect( + sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: minimalConfig, + }) + ).rejects.toThrow(/make sure the local AI server is running/); + }); + + it('hints at rate limiting on a 429', async () => { + // The objective names 429 explicitly: the message must stay an AI + // Provider Error but add what to do about it. + // $FlowFixMe + axios.post.mockRejectedValueOnce({ + response: { + status: 429, + data: { error: { message: 'Too Many Requests' } }, + }, + }); + + await expect( + sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: minimalConfig, + }) + ).rejects.toThrow( + /^AI Provider Error \(429\): Too Many Requests .*rate/i + ); + }); + + it('hints at a temporary provider outage on 502/503', async () => { + for (const status of [502, 503]) { + // $FlowFixMe + axios.post.mockRejectedValueOnce({ + response: { + status, + data: { error: { message: 'upstream unavailable' } }, + }, + }); + await expect( + sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: minimalConfig, + }) + ).rejects.toThrow(/temporarily unavailable|bad gateway/i); + } + }); + + it('leaves unmatched provider errors untouched', async () => { + // $FlowFixMe + axios.post.mockRejectedValueOnce({ + response: { + status: 500, + data: { error: { message: 'internal shuffle failure' } }, + }, + }); + + await expect( + sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: minimalConfig, + }) + ).rejects.toThrow( + /^AI Provider Error \(500\): internal shuffle failure$/ + ); + }); + }); + + describe('SSE streaming', () => { + const streamConfig = { + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'qwen2.5-coder', + temperature: 0.7, + streaming: true, + }; + const sseBody = [ + 'data: ' + + JSON.stringify({ + choices: [{ delta: { role: 'assistant', content: 'Hel' } }], + }) + + '\n', + 'data: ' + + JSON.stringify({ choices: [{ delta: { content: 'lo' } }] }) + + '\n', + 'data: ' + + JSON.stringify({ + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: 'c1', + function: { name: 'create_', arguments: '{"scene_name' }, + }, + ], + }, + }, + ], + }) + + '\n', + 'data: ' + + JSON.stringify({ + choices: [ + { + delta: { + tool_calls: [ + { index: 0, function: { arguments: '":"Menu"}' } }, + ], + }, + }, + ], + }) + + '\n', + 'data: ' + + JSON.stringify({ + choices: [{ delta: {}, finish_reason: 'tool_calls' }], + }) + + '\n', + 'data: [DONE]\n', + ].join(''); + const mockStreamResponse = body => { + const encoder = new TextEncoder(); + // Node's jest env has no ReadableStream global: a minimal reader mock. + const reader = { + read: (() => { + let done = false; + return async () => { + if (done) return { done: true, value: undefined }; + done = true; + return { done: false, value: encoder.encode(body) }; + }; + })(), + }; + return { ok: true, status: 200, body: { getReader: () => reader } }; + }; + beforeEach(() => { + _resetForStreamTests(); + }); + + it('does not corrupt arguments when a chunk sends them as an object', async () => { + // Streaming fragments are strings by spec, but a proxy may send a whole + // object on one chunk; the old `arguments || ''` kept the object and a + // later string fragment then concatenated as '[object Object]...'. + const sse = + 'data: ' + + JSON.stringify({ + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: 'c1', + function: { + name: 'create_scene', + arguments: { scene_name: 'Town' }, + }, + }, + ], + }, + }, + ], + }) + + '\n' + + 'data: ' + + JSON.stringify({ + choices: [{ delta: {}, finish_reason: 'tool_calls' }], + }) + + '\n' + + 'data: [DONE]\n'; + global.fetch = jest.fn().mockResolvedValue(mockStreamResponse(sse)); + + const message = await sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: streamConfig, + }); + + expect(message.tool_calls).toHaveLength(1); + const args = message.tool_calls[0].function.arguments; + expect(args).not.toContain('[object Object]'); + expect(JSON.parse(args)).toEqual({ scene_name: 'Town' }); + }); + + it('reports reasoning as streaming progress before any answer token', async () => { + // Live finding (Ollama, deepseek-v4.1-flash): a reasoning model streams + // many `delta.reasoning` chunks before the first `delta.content`, so the + // partial-content signal stayed empty and the chat's cold-start hint + // claimed it was still waiting for the first token while bytes arrived. + const sse = + 'data: ' + + JSON.stringify({ + choices: [{ delta: { reasoning: 'Let me think' } }], + }) + + '\n' + + 'data: ' + + JSON.stringify({ choices: [{ delta: { reasoning: ' about it' } }] }) + + '\n' + + 'data: ' + + JSON.stringify({ choices: [{ delta: { content: 'Done' } }] }) + + '\n' + + 'data: [DONE]\n'; + global.fetch = jest.fn().mockResolvedValue(mockStreamResponse(sse)); + const deltas = []; + + await sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: streamConfig, + onStreamDelta: partial => deltas.push(partial), + }); + + // The first progress report is non-empty (the reasoning), not ''. + expect(deltas[0]).toBeTruthy(); + expect(deltas[0]).toContain('Let me think'); + // Once the answer arrives it takes over. + expect(deltas[deltas.length - 1]).toBe('Done'); + }); + + it('assembles content and tool calls from SSE chunks', async () => { + global.fetch = jest.fn().mockResolvedValue(mockStreamResponse(sseBody)); + + const message = await sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: streamConfig, + }); + + expect(message.content).toBe('Hello'); + expect(message.tool_calls).toHaveLength(1); + expect(message.tool_calls[0].function.name).toBe('create_'); + expect(message.tool_calls[0].function.arguments).toBe( + '{"scene_name":"Menu"}' + ); + const fetchArgs = global.fetch.mock.calls[0]; + expect(JSON.parse(fetchArgs[1].body).stream).toBe(true); + }); + + it('does not merge a second tool call that omits index', async () => { + const sse = [ + 'data: ' + + JSON.stringify({ + choices: [ + { + delta: { + tool_calls: [ + { + id: 'a', + function: { name: 'one', arguments: '{"x":1}' }, + }, + ], + }, + }, + ], + }) + + '\n', + 'data: ' + + JSON.stringify({ + choices: [ + { + delta: { + tool_calls: [ + { + id: 'b', + function: { name: 'two', arguments: '{"y":2}' }, + }, + ], + }, + }, + ], + }) + + '\n', + 'data: ' + + JSON.stringify({ + choices: [{ delta: {}, finish_reason: 'tool_calls' }], + }) + + '\n', + 'data: [DONE]\n', + ].join(''); + global.fetch = jest.fn().mockResolvedValue(mockStreamResponse(sse)); + + const message = await sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: streamConfig, + }); + + expect(message.tool_calls).toHaveLength(2); + expect(message.tool_calls[0].function.name).toBe('one'); + expect(message.tool_calls[1].function.name).toBe('two'); + expect(message.tool_calls[0].id).toBe('a'); + expect(message.tool_calls[1].id).toBe('b'); + expect(axios.post).not.toHaveBeenCalled(); + }); + + it('continues argument-only fragments without index on the open call', async () => { + const sse = [ + 'data: ' + + JSON.stringify({ + choices: [ + { + delta: { + tool_calls: [ + { id: 'c1', function: { name: 'cre', arguments: '' } }, + ], + }, + }, + ], + }) + + '\n', + 'data: ' + + JSON.stringify({ + choices: [ + { + delta: { + tool_calls: [{ function: { name: 'ate_x' } }], + }, + }, + ], + }) + + '\n', + 'data: ' + + JSON.stringify({ + choices: [ + { + delta: { + tool_calls: [{ function: { arguments: '({"a":1})' } }], + }, + }, + ], + }) + + '\n', + 'data: ' + + JSON.stringify({ + choices: [{ delta: {}, finish_reason: 'tool_calls' }], + }) + + '\n', + 'data: [DONE]\n', + ].join(''); + global.fetch = jest.fn().mockResolvedValue(mockStreamResponse(sse)); + + const message = await sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: streamConfig, + }); + + expect(message.tool_calls).toHaveLength(1); + expect(message.tool_calls[0].function.name).toBe('create_x'); + expect(message.tool_calls[0].function.arguments).toBe('({"a":1})'); + expect(axios.post).not.toHaveBeenCalled(); + }); + + it('skips malformed chunks and empty streams fail loudly', async () => { + global.fetch = jest + .fn() + .mockResolvedValue( + mockStreamResponse( + 'data: {broken json\n' + + 'data: {"choices":[{"delta":{"content":"ok"}}]}\n' + + 'data: [DONE]\n' + ) + ); + const message = await sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: streamConfig, + }); + expect(message.content).toBe('ok'); + + // An empty stream falls back to the non-streaming request (by design): + global.fetch = jest.fn().mockResolvedValue(mockStreamResponse('')); + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [{ message: { role: 'assistant', content: 'recovered' } }], + }, + }); + const recovered = await sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: streamConfig, + }); + expect(recovered.content).toBe('recovered'); + }); + + it('falls back to a non-streaming request when streaming fails', async () => { + global.fetch = jest.fn().mockRejectedValue( + Object.assign(new TypeError('fetch failed'), { + isStreamUnsupported: false, + }) + ); + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [{ message: { role: 'assistant', content: 'fallback' } }], + }, + }); + + const message = await sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: streamConfig, + }); + expect(message.content).toBe('fallback'); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(axios.post).toHaveBeenCalledTimes(1); + }); + + it('does not retry non-streaming after a user abort mid-stream', async () => { + const controller = new AbortController(); + // Stream fails because the user aborted mid-request. + global.fetch = jest + .fn() + .mockRejectedValue(new Error('AI request was aborted.')); + const pending = sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: streamConfig, + signal: controller.signal, + }); + controller.abort(); + + await expect(pending).rejects.toThrow(/aborted/); + expect(axios.post).not.toHaveBeenCalled(); + }); + + it('recovers when the connection drops after partial content', async () => { + const encoder = new TextEncoder(); + let readCount = 0; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + body: { + getReader: () => ({ + read: async () => { + readCount += 1; + if (readCount === 1) { + return { + done: false, + value: encoder.encode( + 'data: ' + + JSON.stringify({ + choices: [{ delta: { content: 'partial ' } }], + }) + + '\n' + ), + }; + } + // Second read: connection split mid-stream. + throw new TypeError('network error'); + }, + }), + }, + }); + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [ + { message: { role: 'assistant', content: 'recovered full' } }, + ], + }, + }); + + const deltas = []; + const message = await sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: streamConfig, + onStreamDelta: partial => deltas.push(partial), + }); + + expect(deltas).toContain('partial '); + expect(message.content).toBe('recovered full'); + expect(axios.post).toHaveBeenCalledTimes(1); + }); + + it('does not start a second non-streaming request after a stream timeout', async () => { + // fetch aborts with AbortError (as when the stream timeout controller fires). + global.fetch = jest.fn().mockImplementation( + () => + new Promise((resolve, reject) => { + const err = new Error('The operation was aborted.'); + err.name = 'AbortError'; + reject(err); + }) + ); + + await expect( + sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: { ...streamConfig, timeoutMs: 5 }, + }) + ).rejects.toThrow(/timed out or was aborted after 5 ms/); + // Critical: no silent second wait of timeoutMs on the non-streaming path. + expect(axios.post).not.toHaveBeenCalled(); + }); + + it('aborts a stalled stream and never retries it non-streaming', async () => { + // A server that accepts the request but never sends a byte (Ollama model + // load stall / VRAM swap / silently dropped connection). The reader + // rejects when the watchdog aborts, exactly like a real fetch body. + global.fetch = (jest.fn(): any).mockImplementation((url, options) => ({ + ok: true, + status: 200, + body: { + getReader: () => ({ + read: () => + new Promise((resolve, reject) => { + options.signal.addEventListener('abort', () => { + const err = new Error('The operation was aborted.'); + err.name = 'AbortError'; + reject(err); + }); + }), + }), + }, + })); + + await expect( + sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: { ...streamConfig, timeoutMs: 20 }, + }) + ).rejects.toThrow(/stream stalled: no data received for 20 ms/); + // Retrying non-streaming would hang another full timeout on the same + // unresponsive server. + // $FlowFixMe[method-unbinding] jest matcher on a typed axios instance. + expect(axios.post).not.toHaveBeenCalled(); + }); + + it('does not kill a slow stream that keeps producing chunks past the timeout', async () => { + const encoder = new TextEncoder(); + const chunk = (content: string) => + 'data: ' + JSON.stringify({ choices: [{ delta: { content } }] }) + '\n'; + // Each chunk is delivered after ~0.53 * timeoutMs, so the total response + // (4 gaps) is more than twice the timeout while no single gap reaches + // it. The watchdog must measure idleness between chunks, not wall-clock + // response duration (the pre-fix code had no deadline at all after the + // headers arrived). + const sleepOrAbort = (ms: number, signal: any) => + new Promise((resolve, reject) => { + const timer = setTimeout(resolve, ms); + if (!signal) return; + const onAbort = () => { + clearTimeout(timer); + const err = new Error('The operation was aborted.'); + err.name = 'AbortError'; + reject(err); + }; + if (signal.aborted) onAbort(); + else signal.addEventListener('abort', onAbort, { once: true }); + }); + let readCount = 0; + global.fetch = (jest.fn(): any).mockImplementation((url, options) => ({ + ok: true, + status: 200, + body: { + getReader: () => ({ + read: async () => { + readCount += 1; + if (readCount === 1) { + return { done: false, value: encoder.encode(chunk('a')) }; + } + if (readCount <= 4) { + await sleepOrAbort(80, options.signal); + return { + done: false, + value: encoder.encode(chunk(String(readCount))), + }; + } + if (readCount === 5) { + return { + done: false, + value: encoder.encode( + 'data: ' + + JSON.stringify({ + choices: [{ delta: {}, finish_reason: 'stop' }], + }) + + '\n' + + 'data: [DONE]\n' + ), + }; + } + return { done: true, value: undefined }; + }, + }), + }, + })); + + const message = await sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: { ...streamConfig, timeoutMs: 150 }, + }); + + expect(message.content).toBe('a234'); + // $FlowFixMe[method-unbinding] jest matcher on a typed axios instance. + expect(axios.post).not.toHaveBeenCalled(); + }); + + it('processes a final data line that has no trailing newline', async () => { + const encoder = new TextEncoder(); + // One complete SSE line WITHOUT a trailing newline (connection close). + const payload = + 'data: ' + + JSON.stringify({ + choices: [{ delta: { content: 'tail' }, finish_reason: 'stop' }], + }); + let readCount = 0; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + body: { + getReader: () => ({ + read: async () => { + readCount += 1; + if (readCount === 1) { + return { done: false, value: encoder.encode(payload) }; + } + return { done: true, value: undefined }; + }, + }), + }, + }); + + const deltas = []; + const message = await sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: streamConfig, + onStreamDelta: partial => deltas.push(partial), + }); + + expect(message.content).toBe('tail'); + expect(deltas).toContain('tail'); + // No fallback: the leftover line was enough to complete the stream. + expect(axios.post).not.toHaveBeenCalled(); + }); + + it('does not lose a partial line that arrives only on the final read before done', async () => { + const encoder = new TextEncoder(); + let readCount = 0; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + body: { + getReader: () => ({ + read: async () => { + readCount += 1; + if (readCount === 1) { + // First read delivers complete lines with newlines. + return { + done: false, + value: encoder.encode( + 'data: ' + + JSON.stringify({ + choices: [{ delta: { content: 'ab' } }], + }) + + '\n\n' + ), + }; + } + // Second read: final chunk without trailing newline, then done on next call. + if (readCount === 2) { + return { + done: false, + value: encoder.encode( + 'data: ' + + JSON.stringify({ + choices: [ + { delta: { content: 'cd' }, finish_reason: 'stop' }, + ], + }) + ), + }; + } + return { done: true, value: undefined }; + }, + }), + }, + }); + + const message = await sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: streamConfig, + }); + + expect(message.content).toBe('abcd'); + expect(axios.post).not.toHaveBeenCalled(); + }); + + it('uses a JSON body when the server ignores stream:true', async () => { + // A proxy that ignores `stream: true` answers with an ordinary + // completion. Reading it as SSE finds no data: lines, so the turn looked + // empty and was retried without streaming — a second full wait for an + // answer that had already arrived. + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { + get: name => + name.toLowerCase() === 'content-type' + ? 'application/json; charset=utf-8' + : null, + }, + text: async () => + JSON.stringify({ + choices: [ + { message: { role: 'assistant', content: 'direct json' } }, + ], + }), + }); + + const message = await sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: streamConfig, + }); + + expect(message.content).toBe('direct json'); + // No retry: the body already held the answer. + // $FlowFixMe[method-unbinding] jest matcher on a typed axios instance. + expect(axios.post).not.toHaveBeenCalled(); + }); + + it('still reads a real event stream', async () => { + // Guard the detection itself: a correct SSE response must keep taking + // the streaming path even though it now inspects the content type. + const encoder = new TextEncoder(); + const sse = + 'data: ' + + JSON.stringify({ + choices: [{ delta: { content: 'via sse' }, finish_reason: 'stop' }], + }) + + '\n'; + let readCount = 0; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { + get: name => + name.toLowerCase() === 'content-type' ? 'text/event-stream' : null, + }, + body: { + getReader: () => ({ + read: async () => { + readCount += 1; + if (readCount === 1) { + return { done: false, value: encoder.encode(sse) }; + } + return { done: true, value: undefined }; + }, + }), + }, + }); + + const message = await sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: streamConfig, + }); + + expect(message.content).toBe('via sse'); + }); + + it('falls back to a non-streaming request when the body is neither SSE nor JSON', async () => { + // A proxy error page (HTML) is a stream failure like any other: the + // stream path reports it and the normal single non-streaming retry + // still runs, which is what recovers the turn when only streaming is + // broken. + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { + get: name => + name.toLowerCase() === 'content-type' ? 'text/html' : null, + }, + text: async () => 'proxy error', + }); + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [{ message: { role: 'assistant', content: 'recovered' } }], + }, + }); + + const message = await sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: streamConfig, + }); + + expect(message.content).toBe('recovered'); + // $FlowFixMe[method-unbinding] jest matcher on a typed axios instance. + expect(axios.post).toHaveBeenCalledTimes(1); + }); + + it('blames the output budget, not the network, when reasoning was truncated', async () => { + const encoder = new TextEncoder(); + const sse = [ + 'data: ' + + JSON.stringify({ + choices: [{ delta: { reasoning_content: 'thinking...' } }], + }) + + '\n', + 'data: ' + + JSON.stringify({ + choices: [{ delta: {}, finish_reason: 'length' }], + }) + + '\n', + 'data: [DONE]\n', + ].join(''); + let readCount = 0; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + body: { + getReader: () => ({ + read: async () => { + readCount += 1; + if (readCount === 1) { + return { done: false, value: encoder.encode(sse) }; + } + return { done: true, value: undefined }; + }, + }), + }, + }); + + // Capture the message once: the fetch mock yields a single stream. + let thrownMessage = ''; + try { + await sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: streamConfig, + }); + } catch (error) { + thrownMessage = error.message; + } + expect(thrownMessage).toMatch(/output tokens/); + // The misleading network diagnosis must be gone. + expect(thrownMessage).not.toMatch(/dropped mid-stream/); + // And no non-streaming retry: the same budget applies there, so the + // user must not wait a second full timeout for an identical failure. + // $FlowFixMe[method-unbinding] jest matcher on a typed axios instance. + expect(axios.post).not.toHaveBeenCalled(); + }); + + it('carries streamed reasoning_content into the assembled message', async () => { + // Reasoning models (DeepSeek-R1, qwq, Ollama reasoning builds) stream + // their chain of thought on `reasoning_content`. The non-streaming path + // reads that field in parseAssistantMessage; the stream must produce the + // same shape or streaming silently drops the thinking. + const encoder = new TextEncoder(); + const sse = [ + 'data: ' + + JSON.stringify({ + choices: [{ delta: { reasoning_content: 'Let me think. ' } }], + }) + + '\n', + 'data: ' + + JSON.stringify({ + choices: [{ delta: { reasoning_content: 'Use a sprite.' } }], + }) + + '\n', + 'data: ' + + JSON.stringify({ + choices: [{ delta: { content: 'Here is the answer.' } }], + }) + + '\n', + 'data: ' + + JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }] }) + + '\n', + 'data: [DONE]\n', + ].join(''); + let readCount = 0; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + body: { + getReader: () => ({ + read: async () => { + readCount += 1; + if (readCount === 1) { + return { done: false, value: encoder.encode(sse) }; + } + return { done: true, value: undefined }; + }, + }), + }, + }); + + const message = await sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: streamConfig, + }); + + expect(message.content).toBe('Here is the answer.'); + expect(message.reasoning_content).toBe('Let me think. Use a sprite.'); + }); + + it('omits reasoning_content when the model streams none', async () => { + const encoder = new TextEncoder(); + const sse = + 'data: ' + + JSON.stringify({ + choices: [{ delta: { content: 'plain' }, finish_reason: 'stop' }], + }) + + '\n'; + let readCount = 0; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + body: { + getReader: () => ({ + read: async () => { + readCount += 1; + if (readCount === 1) { + return { done: false, value: encoder.encode(sse) }; + } + return { done: true, value: undefined }; + }, + }), + }, + }); + + const message = await sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: streamConfig, + }); + + expect(message.content).toBe('plain'); + expect(message.reasoning_content).toBeUndefined(); + }); + }); + + describe('per-request model override', () => { + it('sends the overridden model for the request turns', async () => { + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + const created = await customCreateAiRequest({ + userRequest: 'start a chat', + }); + + customSetAiRequestModelOverride(created.id, 'deepseek-chat'); + expect(customGetAiRequestModelOverride(created.id)).toBe('deepseek-chat'); + + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + await customAddMessageToAiRequest({ + aiRequestId: created.id, + userMessage: 'continue', + }); + + const requestPayload = axios.post.mock.calls[1][1]; + expect(requestPayload.model).toBe('deepseek-chat'); + + // Empty string clears the override: back to the global model. + customSetAiRequestModelOverride(created.id, ''); + expect(customGetAiRequestModelOverride(created.id)).toBe(''); + }); + + it('sub-agents inherit the parent chat model override', async () => { + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + const parent = await customCreateAiRequest({ + userRequest: 'orchestrate', + }); + customSetAiRequestModelOverride(parent.id, 'llama3.2'); + + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + await customCreateSubAgentAiRequest({ + parentAiRequestId: parent.id, + roleId: 'developer', + userRequest: 'do the work', + }); + + const requestPayload = axios.post.mock.calls[1][1]; + expect(requestPayload.model).toBe('llama3.2'); + }); + + it('keeps the global model when no override is set', async () => { + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + const created = await customCreateAiRequest({ + userRequest: 'start a chat', + }); + expect(customGetAiRequestModelOverride(created.id)).toBe(''); + const requestPayload = axios.post.mock.calls[0][1]; + expect(requestPayload.model).toBe('qwen2.5-coder'); + }); + + it('sends the overridden model for next-step suggestions', async () => { + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + const created = await customCreateAiRequest({ + userRequest: 'start a chat', + }); + customSetAiRequestModelOverride(created.id, 'deepseek-chat'); + + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [ + { + message: { + role: 'assistant', + content: JSON.stringify({ + explanationMessage: 'Next:', + suggestions: [{ title: 'Go', suggestedMessage: 'Do it' }], + }), + }, + }, + ], + }, + }); + await customGetAiRequestSuggestions(created.id); + + const requestPayload = axios.post.mock.calls[1][1]; + expect(requestPayload.model).toBe('deepseek-chat'); + }); + + it('sends the overridden model for related event generation', async () => { + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + const created = await customCreateAiRequest({ + userRequest: 'chat before generate', + }); + customSetAiRequestModelOverride(created.id, 'llama3.2'); + + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [ + { + message: { + role: 'assistant', + content: + '```json\n[{"type":"add_events","events":[{"type":"BuiltinCommonInstructions::Standard","conditions":[],"actions":[]}]}]\n```', + }, + }, + ], + }, + }); + await customCreateAiGeneratedEvent({ + sceneName: 'MainScene', + eventsDescription: 'Move player', + eventBatches: null, + extensionNamesList: '', + objectsList: 'Player', + existingEventsAsText: '', + aiRequestId: created.id, + }); + + const requestPayload = axios.post.mock.calls[1][1]; + expect(requestPayload.model).toBe('llama3.2'); + customSetAiRequestModelOverride(created.id, ''); + }); + }); + + describe('per-chat model choice survives a reload', () => { + const memoryStorage = {}; + const fakeStorage = { + getItem: key => (key in memoryStorage ? memoryStorage[key] : null), + setItem: (key, value) => { + memoryStorage[key] = String(value); + }, + removeItem: key => { + delete memoryStorage[key]; + }, + }; + beforeEach(() => { + Object.keys(memoryStorage).forEach(key => delete memoryStorage[key]); + global.localStorage = fakeStorage; + _resetCustomAiClientForTesting(); + }); + afterEach(() => { + delete global.localStorage; + }); + + it('reloads the override that was set in a previous session', () => { + // The override lives outside the AiRequest record, so a reload used to + // revert the chat to the global model — often a different, sometimes + // unavailable model, in a BYOK setup. + customSetAiRequestModelOverride('local-ai-1', 'deepseek-r1:14b'); + expect( + JSON.parse(memoryStorage['gd-custom-ai-model-overrides'])['local-ai-1'] + ).toBe('deepseek-r1:14b'); + + // Simulate a fresh session: drop in-memory state, then re-read. + _resetCustomAiClientForTesting(); + expect(customGetAiRequestModelOverride('local-ai-1')).toBe(''); + loadLocalAiRequests(); + loadLocalAiRequestModelOverridesForTesting(); + expect(customGetAiRequestModelOverride('local-ai-1')).toBe( + 'deepseek-r1:14b' + ); + }); + + it('clears the stored override when the model is reset to empty', () => { + customSetAiRequestModelOverride('local-ai-2', 'qwen2.5-coder'); + customSetAiRequestModelOverride('local-ai-2', ''); + const persisted = JSON.parse( + memoryStorage['gd-custom-ai-model-overrides'] + ); + expect(persisted['local-ai-2']).toBeUndefined(); + }); + + it('ignores a corrupt override payload without throwing', () => { + memoryStorage['gd-custom-ai-model-overrides'] = '{not json'; + expect(() => loadLocalAiRequestModelOverridesForTesting()).not.toThrow(); + expect(customGetAiRequestModelOverride('local-ai-3')).toBe(''); + // A non-string value must not become a model name. + memoryStorage['gd-custom-ai-model-overrides'] = JSON.stringify({ + 'local-ai-4': 42, + 'local-ai-5': ' ', + 'local-ai-6': 'ok', + }); + loadLocalAiRequestModelOverridesForTesting(); + expect(customGetAiRequestModelOverride('local-ai-4')).toBe(''); + expect(customGetAiRequestModelOverride('local-ai-5')).toBe(''); + expect(customGetAiRequestModelOverride('local-ai-6')).toBe('ok'); + }); + }); + + describe('per-agent token totals survive a reload', () => { + const memoryStorage = {}; + const fakeStorage = { + getItem: key => (key in memoryStorage ? memoryStorage[key] : null), + setItem: (key, value) => { + memoryStorage[key] = String(value); + }, + removeItem: key => { + delete memoryStorage[key]; + }, + }; + beforeEach(() => { + Object.keys(memoryStorage).forEach(key => delete memoryStorage[key]); + global.localStorage = fakeStorage; + _resetCustomAiClientForTesting(); + }); + afterEach(() => { + delete global.localStorage; + }); + + it('persists a sub-agent total and reloads it in a fresh session', () => { + // Sub-agent meters were in-memory only, so the per-agent audit dashboard + // lost every figure (children included) on the next page load. + recordLocalAiRequestTokenUsageForTesting('sub-1', 100, 50); + expect( + JSON.parse(memoryStorage['gd-custom-ai-token-totals'])['sub-1'] + ).toBe(150); + + // Simulate a fresh session: drop in-memory state, then re-read. + _resetCustomAiClientForTesting(); + expect(customGetAiRequestTokenTotal('sub-1')).toBe(0); + loadLocalAiRequestTokenTotalsForTesting(); + expect(customGetAiRequestTokenTotal('sub-1')).toBe(150); + }); + + it('accumulates across turns before persisting', () => { + recordLocalAiRequestTokenUsageForTesting('sub-2', 10, 5); + recordLocalAiRequestTokenUsageForTesting('sub-2', 20, 15); + expect(customGetAiRequestTokenTotal('sub-2')).toBe(50); + expect( + JSON.parse(memoryStorage['gd-custom-ai-token-totals'])['sub-2'] + ).toBe(50); + }); + + it('ignores a corrupt payload and non-finite / negative values', () => { + memoryStorage['gd-custom-ai-token-totals'] = '{not json'; + expect(() => loadLocalAiRequestTokenTotalsForTesting()).not.toThrow(); + expect(customGetAiRequestTokenTotal('local-ai-x')).toBe(0); + + memoryStorage['gd-custom-ai-token-totals'] = JSON.stringify({ + 'local-ai-neg': -5, + 'local-ai-str': '42', + 'local-ai-null': null, + 'local-ai-bool': true, + 'local-ai-ok': 42, + }); + loadLocalAiRequestTokenTotalsForTesting(); + expect(customGetAiRequestTokenTotal('local-ai-neg')).toBe(0); + expect(customGetAiRequestTokenTotal('local-ai-str')).toBe(0); + expect(customGetAiRequestTokenTotal('local-ai-null')).toBe(0); + expect(customGetAiRequestTokenTotal('local-ai-bool')).toBe(0); + expect(customGetAiRequestTokenTotal('local-ai-ok')).toBe(42); + }); + + it('caps the stored map so it cannot grow without bound', () => { + for (let i = 0; i < 520; i++) { + recordLocalAiRequestTokenUsageForTesting(`local-ai-${i}`, 1, 0); + } + const persisted = JSON.parse(memoryStorage['gd-custom-ai-token-totals']); + expect(Object.keys(persisted).length).toBe(500); + // The oldest entries are the ones dropped. + expect(persisted['local-ai-0']).toBeUndefined(); + expect(persisted['local-ai-519']).toBe(1); + }); + }); + + describe('sub-agent requests survive a reload', () => { + const memoryStorage = {}; + const fakeStorage = { + getItem: key => (key in memoryStorage ? memoryStorage[key] : null), + setItem: (key, value) => { + memoryStorage[key] = String(value); + }, + removeItem: key => { + delete memoryStorage[key]; + }, + }; + beforeEach(() => { + Object.keys(memoryStorage).forEach(key => delete memoryStorage[key]); + global.localStorage = fakeStorage; + _resetCustomAiClientForTesting(); + }); + afterEach(() => { + delete global.localStorage; + }); + + const makeChild = async (parentId, roleId) => { + setCustomEndpointConfig({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'llama3.2', + temperature: 0.7, + }); + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + return customCreateSubAgentAiRequest({ + parentAiRequestId: parentId, + roleId, + userRequest: 'do it', + gameProjectJson: null, + projectSpecificExtensionsSummaryJson: null, + spawnContextNote: null, + }); + }; + + it('persists a child in its own store, not the parent chat list', async () => { + const child = await makeChild('local-ai-parent-1', 'developer'); + const childStore = JSON.parse( + memoryStorage['gd-custom-ai-subagent-requests'] || '{}' + ); + expect(Object.keys(childStore)).toContain(child.id); + const parentStore = JSON.parse( + memoryStorage['gd-custom-ai-requests'] || '{}' + ); + expect(Object.keys(parentStore)).not.toContain(child.id); + }); + + it('reloads the child in a fresh session', async () => { + const child = await makeChild('local-ai-parent-2', 'tester'); + _resetCustomAiClientForTesting(); + // Gone from memory: the fallback carries no parent link. + expect(customGetAiRequest(child.id).parentAiRequestId).toBeUndefined(); + loadLocalAiRequests(); + loadLocalAiSubAgentRequestsForTesting(); + const reloaded = customGetAiRequest(child.id); + expect(reloaded.parentAiRequestId).toBe('local-ai-parent-2'); + expect(reloaded.studioRoleId).toBe('tester'); + }); + + it('ignores a corrupt child store payload without throwing', () => { + memoryStorage['gd-custom-ai-subagent-requests'] = '{not json'; + expect(() => loadLocalAiSubAgentRequestsForTesting()).not.toThrow(); + // An entry without a parent link is not a child and is dropped. + memoryStorage['gd-custom-ai-subagent-requests'] = JSON.stringify({ + 'local-ai-orphan': { id: 'local-ai-orphan', status: 'ready' }, + }); + loadLocalAiSubAgentRequestsForTesting(); + expect( + customGetAiRequest('local-ai-orphan').parentAiRequestId + ).toBeUndefined(); + }); + }); + + describe('legacy persisted chats render after upgrading', () => { + const memoryStorage = {}; + const fakeStorage = { + getItem: key => (key in memoryStorage ? memoryStorage[key] : null), + setItem: (key, value) => { + memoryStorage[key] = String(value); + }, + removeItem: key => { + delete memoryStorage[key]; + }, + }; + beforeEach(() => { + Object.keys(memoryStorage).forEach(key => delete memoryStorage[key]); + global.localStorage = fakeStorage; + _resetCustomAiClientForTesting(); + }); + afterEach(() => { + delete global.localStorage; + }); + + it('replaces a non-array stored output with an empty array on load', () => { + // The loader validates only id/status, so a truthy non-array `output` + // reaches every consumer — a spread or `.map` over it throws and fails the + // whole turn. normalizePersistedMessages is the one choke point. + fakeStorage.setItem( + 'gd-custom-ai-requests', + JSON.stringify({ + 'local-ai-bad-output': { + id: 'local-ai-bad-output', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + userId: LOCAL_BYOK_USER_ID, + status: 'ready', + error: null, + output: { not: 'an array' }, + }, + }) + ); + _resetCustomAiClientForTesting(); + const loaded = loadLocalAiRequests(); + + expect(loaded['local-ai-bad-output'].output).toEqual([]); + }); + + it("rewrites a stored 'text' entry to output_text on load", () => { + // Chats persisted before the parser emitted output_text carry 'text', + // which no renderer handles — the answer would be invisible on reload. + fakeStorage.setItem( + 'gd-custom-ai-requests', + JSON.stringify({ + 'local-ai-old': { + id: 'local-ai-old', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + userId: LOCAL_BYOK_USER_ID, + status: 'ready', + error: null, + output: [ + { + type: 'message', + role: 'assistant', + status: 'completed', + messageId: 'm1', + content: [ + { type: 'text', status: 'completed', text: 'Old answer.' }, + ], + }, + ], + }, + }) + ); + + const loaded = loadLocalAiRequests(); + const entry = loaded['local-ai-old'].output[0].content[0]; + expect(entry.type).toBe('output_text'); + expect(entry.text).toBe('Old answer.'); + expect(entry.annotations).toEqual([]); + }); + + it('leaves an already-correct entry untouched by identity', () => { + const message = { + type: 'message', + role: 'assistant', + status: 'completed', + messageId: 'm2', + content: [ + { + type: 'output_text', + status: 'completed', + text: 'Fine.', + annotations: [], + }, + ], + }; + fakeStorage.setItem( + 'gd-custom-ai-requests', + JSON.stringify({ + 'local-ai-ok': { + id: 'local-ai-ok', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + userId: LOCAL_BYOK_USER_ID, + status: 'ready', + error: null, + output: [message], + }, + }) + ); + + const loaded = loadLocalAiRequests(); + expect(loaded['local-ai-ok'].output[0]).toEqual(message); + }); + }); + + describe('persisted request pruning keeps the most recent chats', () => { + const memoryStorage = {}; + const fakeStorage = { + getItem: key => (key in memoryStorage ? memoryStorage[key] : null), + setItem: (key, value) => { + memoryStorage[key] = String(value); + }, + removeItem: key => { + delete memoryStorage[key]; + }, + }; + beforeEach(() => { + Object.keys(memoryStorage).forEach(key => delete memoryStorage[key]); + global.localStorage = fakeStorage; + _resetCustomAiClientForTesting(); + }); + afterEach(() => { + delete global.localStorage; + }); + + const makeRequest = (id, updatedAt) => ({ + id, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt, + userId: LOCAL_BYOK_USER_ID, + status: 'ready', + error: null, + output: [], + }); + + it('does not let sub-agent children evict real chats from persistence', () => { + // Children are internal (excluded from history) but were persisted, + // consuming the 20-slot recency budget: after a multi-agent run the + // newest real chats could vanish on reload. + const base = Date.parse('2026-01-01T00:00:00.000Z'); + const nowSpy = jest.spyOn(Date, 'now'); + // 20 real chats, oldest first. + for (let i = 0; i < 20; i++) { + nowSpy.mockReturnValue(base + i * 1000); + customUpdateAiRequest( + makeRequest( + `local-ai-real-${i}`, + new Date(base + i * 1000).toISOString() + ) + ); + } + // 5 sub-agent children, newer than every real chat. + for (let i = 0; i < 5; i++) { + nowSpy.mockReturnValue(base + 100000 + i * 1000); + customUpdateAiRequest( + ({ + ...makeRequest( + `local-ai-child-${i}`, + new Date(base + 100000 + i * 1000).toISOString() + ), + parentAiRequestId: 'local-ai-real-19', + }: any) + ); + } + // Read what was actually PERSISTED: loadLocalAiRequests merges storage + // into the in-memory cache (which still holds the children), so it would + // mask the bug. + const persistedIds = Object.keys( + JSON.parse(memoryStorage['gd-custom-ai-requests'] || '{}') + ); + expect( + persistedIds.filter(id => id.startsWith('local-ai-child-')) + ).toEqual([]); + expect( + persistedIds.filter(id => id.startsWith('local-ai-real-')) + ).toHaveLength(20); + nowSpy.mockRestore(); + }); + + it('drops the least recently updated chat, not the most recently created', () => { + // Object key order is insertion order, so re-assigning an older chat + // after a new turn does not move it. Pruning by key order would drop a + // newer conversation while keeping the stale one that was just continued. + const base = Date.parse('2026-01-01T00:00:00.000Z'); + const nowSpy = jest.spyOn(Date, 'now'); + // Seed 20 chats, oldest first (id-0 oldest). + for (let i = 0; i < 20; i++) { + nowSpy.mockReturnValue(base + i * 1000); + customUpdateAiRequest( + makeRequest(`local-ai-${i}`, new Date(base + i * 1000).toISOString()) + ); + } + expect(Object.keys(loadLocalAiRequests())).toHaveLength(20); + + // Continue the OLDEST chat: it becomes the most recently updated. + const newest = base + 20 * 1000; + nowSpy.mockReturnValue(newest); + customUpdateAiRequest( + makeRequest('local-ai-0', new Date(newest).toISOString()) + ); + // Add one more so the cache exceeds the cap and pruning runs. + nowSpy.mockReturnValue(newest + 1000); + customUpdateAiRequest( + makeRequest('local-ai-20', new Date(newest + 1000).toISOString()) + ); + + saveLocalAiRequests(); + const persisted = JSON.parse( + memoryStorage['gd-custom-ai-requests'] || '{}' + ); + const ids = Object.keys(persisted); + expect(ids).toHaveLength(20); + // The just-continued old chat and the brand-new one are kept. + expect(ids).toContain('local-ai-0'); + expect(ids).toContain('local-ai-20'); + // The stalest one was dropped instead. + expect(ids).not.toContain('local-ai-1'); + nowSpy.mockRestore(); + }); + }); + + describe('persisted config sanitization', () => { + // The suite runs in the Node jest environment: no localStorage. + const memoryStorage = {}; + const fakeStorage = { + getItem: key => (key in memoryStorage ? memoryStorage[key] : null), + setItem: (key, value) => { + memoryStorage[key] = String(value); + }, + removeItem: key => { + delete memoryStorage[key]; + }, + }; + beforeEach(() => { + Object.keys(memoryStorage).forEach(key => delete memoryStorage[key]); + global.localStorage = fakeStorage; + }); + afterEach(() => { + delete global.localStorage; + }); + + it('falls back to defaults when localStorage holds a corrupt config', () => { + fakeStorage.setItem('gd-custom-ai-config', '{not json'); + _resetCustomAiClientForTesting(); + const config = getCustomEndpointConfig(); + expect(config.baseUrl).toBe('http://localhost:11434/v1'); + expect(config.temperature).toBe(0.7); + fakeStorage.removeItem('gd-custom-ai-config'); + _resetCustomAiClientForTesting(); + }); + + it('coerces mistyped fields and clamps temperature on load', () => { + fakeStorage.setItem( + 'gd-custom-ai-config', + JSON.stringify({ + enabled: 'yes', + baseUrl: 1234, + model: null, + temperature: 42, + timeoutMs: 'fast', + maxTokens: -3, + customHeaders: ['nope'], + }) + ); + _resetCustomAiClientForTesting(); + const config = getCustomEndpointConfig(); + expect(config.enabled).toBe(false); + expect(config.baseUrl).toBe(''); + expect(config.model).toBe(''); + // Out-of-range temperature is clamped into [0, 1] like the dialog does. + expect(config.temperature).toBe(1); + expect(config.timeoutMs).toBeUndefined(); + expect(config.maxTokens).toBeUndefined(); + + fakeStorage.removeItem('gd-custom-ai-config'); + _resetCustomAiClientForTesting(); + }); + + it('clamps an oversized timeout on load so setTimeout cannot overflow', () => { + // setTimeout (and axios, which wraps it) stores its delay in a 32-bit + // signed integer; beyond that the runtime clamps it to 1 ms, which + // aborted every request almost immediately with a "timed out" message. + fakeStorage.setItem( + 'gd-custom-ai-config', + JSON.stringify({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + model: 'qwen2.5-coder', + timeoutMs: 3000000000, + }) + ); + _resetCustomAiClientForTesting(); + const config = getCustomEndpointConfig(); + expect(config.timeoutMs).toBe(MAX_TIMEOUT_MS); + // The bound must be below the overflow threshold for this to mean anything. + expect(MAX_TIMEOUT_MS).toBeLessThan(3000000000); + expect(config.customHeaders).toBeUndefined(); + fakeStorage.removeItem('gd-custom-ai-config'); + _resetCustomAiClientForTesting(); + }); + + it('keeps well-typed persisted values through sanitization', () => { + fakeStorage.setItem( + 'gd-custom-ai-config', + JSON.stringify({ + enabled: true, + baseUrl: 'http://localhost:1234/v1', + model: 'mistral-7b', + temperature: 0.9, + timeoutMs: 300000, + maxTokens: 2048, + customHeaders: { 'X-Custom': 'value' }, + }) + ); + _resetCustomAiClientForTesting(); + const config = getCustomEndpointConfig(); + expect(config.enabled).toBe(true); + expect(config.baseUrl).toBe('http://localhost:1234/v1'); + expect(config.model).toBe('mistral-7b'); + expect(config.temperature).toBe(0.9); + expect(config.timeoutMs).toBe(300000); + expect(config.maxTokens).toBe(2048); + expect(config.customHeaders).toEqual({ 'X-Custom': 'value' }); + fakeStorage.removeItem('gd-custom-ai-config'); + _resetCustomAiClientForTesting(); + }); + + it('sanitizes mistyped updates so the live cache cannot be poisoned', () => { + setCustomEndpointConfig({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + model: 'qwen2.5-coder', + temperature: 0.5, + }); + // $FlowFixMe deliberately mistyped updates + setCustomEndpointConfig({ + enabled: 'yes', + baseUrl: 1234, + model: null, + temperature: 42, + timeoutMs: 'fast', + maxTokens: -3, + customHeaders: ['nope'], + streaming: 'on', + }); + const config = getCustomEndpointConfig(); + expect(config.enabled).toBe(false); + expect(config.baseUrl).toBe(''); + expect(config.model).toBe(''); + expect(config.temperature).toBe(1); + expect(config.timeoutMs).toBeUndefined(); + expect(config.maxTokens).toBeUndefined(); + expect(config.customHeaders).toBeUndefined(); + expect(config.streaming).toBe(false); + // The sanitized shape is what a later get() returns without re-reading + // localStorage (short-circuit on cache). + expect(getCustomEndpointConfig()).toEqual(config); + fakeStorage.removeItem('gd-custom-ai-config'); + _resetCustomAiClientForTesting(); + }); + + it('keeps a valid apiKey through set sanitization', () => { + setCustomEndpointConfig({ + enabled: true, + baseUrl: 'https://api.openrouter.ai/api/v1', + apiKey: 'sk-live-key', + model: 'deepseek/deepseek-chat', + temperature: 0.3, + }); + const config = getCustomEndpointConfig(); + expect(config.apiKey).toBe('sk-live-key'); + expect(config.enabled).toBe(true); + // Persisted payload must not contain the cleartext key. + const persisted = JSON.parse( + memoryStorage['gd-custom-ai-config'] || 'null' + ); + expect(persisted).toBeTruthy(); + expect(persisted.apiKey).toBeUndefined(); + fakeStorage.removeItem('gd-custom-ai-config'); + _resetCustomAiClientForTesting(); + }); + + it('falls back to defaults when localStorage holds a JSON null / scalar', () => { + fakeStorage.setItem('gd-custom-ai-config', 'null'); + _resetCustomAiClientForTesting(); + let config = getCustomEndpointConfig(); + expect(config.baseUrl).toBe(DEFAULT_CUSTOM_AI_CONFIG.baseUrl); + expect(config.enabled).toBe(false); + + fakeStorage.setItem('gd-custom-ai-config', '42'); + _resetCustomAiClientForTesting(); + config = getCustomEndpointConfig(); + expect(config.baseUrl).toBe(DEFAULT_CUSTOM_AI_CONFIG.baseUrl); + + fakeStorage.removeItem('gd-custom-ai-config'); + _resetCustomAiClientForTesting(); + }); + + it('ignores non-object request-cache JSON and keeps valid entries', () => { + fakeStorage.setItem('gd-custom-ai-requests', 'not-an-object'); + _resetCustomAiClientForTesting(); + expect(() => loadLocalAiRequests()).not.toThrow(); + expect(Object.keys(loadLocalAiRequests())).toHaveLength(0); + + fakeStorage.setItem('gd-custom-ai-requests', JSON.stringify([1, 2, 3])); + _resetCustomAiClientForTesting(); + loadLocalAiRequests(); + expect(Object.keys(loadLocalAiRequests())).toHaveLength(0); + + fakeStorage.setItem( + 'gd-custom-ai-requests', + JSON.stringify({ + 'local-ai-good': { + id: 'local-ai-good', + status: 'ready', + createdAt: '2020-01-01T00:00:00.000Z', + updatedAt: '2020-01-01T00:00:00.000Z', + userId: LOCAL_BYOK_USER_ID, + error: null, + output: [], + }, + 'local-ai-bad': null, + 'local-ai-also-bad': { notId: true }, + scalar: 7, + arr: [], + }) + ); + _resetCustomAiClientForTesting(); + const loaded = loadLocalAiRequests(); + expect(Object.keys(loaded)).toEqual(['local-ai-good']); + // List path must not throw on the filtered cache. + expect(() => customGetAiRequests()).not.toThrow(); + expect(customGetAiRequests().aiRequests.map(r => r.id)).toEqual([ + 'local-ai-good', + ]); + + fakeStorage.removeItem('gd-custom-ai-requests'); + _resetCustomAiClientForTesting(); + }); + + it('does not throw when localStorage.setItem rejects on request save', async () => { + const consoleWarn = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}); + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [{ message: { role: 'assistant', content: 'ok' } }], + }, + }); + const created = await customCreateAiRequest({ + userRequest: 'quota test', + gameProjectJson: null, + projectSpecificExtensionsSummaryJson: null, + mode: 'chat', + aiConfiguration: { presetId: 'default' }, + gameId: null, + }); + expect(created.id).toMatch(/^local-ai-/); + + // Swap in a storage that always throws (quota exceeded / SecurityError). + const throwingStorage = { + getItem: () => null, + setItem: () => { + throw new Error('QuotaExceededError'); + }, + removeItem: () => {}, + }; + global.localStorage = throwingStorage; + expect(() => saveLocalAiRequests()).not.toThrow(); + expect(() => customDeleteAiRequest(created.id)).not.toThrow(); + expect(consoleWarn).toHaveBeenCalled(); + consoleWarn.mockRestore(); + delete global.localStorage; + _resetCustomAiClientForTesting(); + }); + it('strips custom headers with CR/LF or hop-by-hop names on load', () => { + fakeStorage.setItem( + 'gd-custom-ai-config', + JSON.stringify({ + baseUrl: 'http://localhost:11434/v1', + customHeaders: { + 'X-Ok': 'yes', + 'X-Evil': 'value\r\nX-Injected: 1', + 'Content-Type': 'text/plain', + Host: 'evil.example', + 'Transfer-Encoding': 'chunked', + 'Bad Name': 'x', + }, + }) + ); + _resetCustomAiClientForTesting(); + const config = getCustomEndpointConfig(); + expect(config.customHeaders).toEqual({ 'X-Ok': 'yes' }); + fakeStorage.removeItem('gd-custom-ai-config'); + _resetCustomAiClientForTesting(); + }); + }); + + describe('sendChatCompletion cancellation', () => { + const minimalConfig = { + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'qwen2.5-coder', + temperature: 0.7, + }; + + it('passes the caller AbortSignal directly to axios', async () => { + const controller = new AbortController(); + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + + await sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: minimalConfig, + signal: controller.signal, + }); + + const postOptions = axios.post.mock.calls[0][2]; + expect(postOptions.signal).toBe(controller.signal); + // The legacy CancelToken bridge must be gone. + expect(postOptions.cancelToken).toBeUndefined(); + }); + + it('does not let custom headers replace Content-Type on the request', async () => { + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [{ message: { role: 'assistant', content: 'ok' } }], + }, + }); + await sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: { + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'qwen2.5-coder', + temperature: 0.7, + customHeaders: { + 'Content-Type': 'text/plain', + 'X-Keep': '1', + 'X-Bad': 'a\nb', + Host: 'evil.example', + }, + }, + }); + const headers = axios.post.mock.calls[0][2].headers; + expect(headers['Content-Type']).toBe('application/json'); + expect(headers['X-Keep']).toBe('1'); + expect(headers['X-Bad']).toBeUndefined(); + expect(headers.Host).toBeUndefined(); + }); + + it('uses a configurable timeout when provided, else the 120s default', async () => { + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + await sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: { ...minimalConfig, timeoutMs: 300000 }, + }); + expect(axios.post.mock.calls[0][2].timeout).toBe(300000); + + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + await sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: { ...minimalConfig, timeoutMs: -5 }, + }); + expect(axios.post.mock.calls[1][2].timeout).toBe(120000); + }); + + it('appends a hint to a 401 when no API key is configured', async () => { + // $FlowFixMe + axios.post.mockRejectedValueOnce({ + response: { + status: 401, + data: { error: { message: 'Invalid API key' } }, + }, + }); + + await expect( + sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: minimalConfig, // apiKey: '' + }) + ).rejects.toThrow(/AI Provider Error \(401\).*No API key is configured/); + + // With a key configured, the raw provider error is passed through. + // $FlowFixMe + axios.post.mockRejectedValueOnce({ + response: { + status: 401, + data: { error: { message: 'Invalid API key' } }, + }, + }); + await expect( + sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: { ...minimalConfig, apiKey: 'sk-test' }, + }) + ).rejects.toThrow(/^AI Provider Error \(401\): Invalid API key$/); + }); + + it('rejects with an abort message when the signal is already aborted', async () => { + const controller = new AbortController(); + controller.abort(); + // $FlowFixMe + axios.post.mockRejectedValueOnce(new Error('canceled')); + + await expect( + sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: minimalConfig, + signal: controller.signal, + }) + ).rejects.toThrow('AI request was aborted.'); + }); + + it('rejects with an abort message when aborted mid-request', async () => { + const controller = new AbortController(); + // $FlowFixMe + axios.post.mockImplementationOnce( + () => + new Promise((resolve, reject) => { + controller.signal.addEventListener('abort', () => + reject(new Error('canceled')) + ); + }) + ); + + const pending = sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: minimalConfig, + signal: controller.signal, + }); + controller.abort(); + + await expect(pending).rejects.toThrow('AI request was aborted.'); + }); + }); + + describe('suspend-time turn cancellation', () => { + const waitFor = condition => { + return new Promise((resolve, reject) => { + let attempts = 0; + const check = () => { + if (condition()) return resolve(); + if (++attempts > 100) return reject(new Error('timeout')); + setTimeout(check, 10); + }; + check(); + }); + }; + + it('aborts the in-flight model call when a request is suspended', async () => { + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + + const created = await customCreateAiRequest({ + userRequest: 'build a scene', + }); + const aiRequestId = created.id; + + // Suspend while the "model" answer is in flight: the turn's post hangs + // until the signal aborts. + axios.post.mockImplementationOnce( + (url, body, options) => + new Promise((resolve, reject) => { + options.signal.addEventListener('abort', () => + reject(new Error('canceled')) + ); + }) + ); + const pending = customAddMessageToAiRequest({ + aiRequestId, + userMessage: 'continue', + }); + await waitFor(() => axios.post.mock.calls.length > 1); + + const signal = axios.post.mock.calls[1][2].signal; + expect(signal.aborted).toBe(false); + + customSuspendAiRequest(aiRequestId); + expect(signal.aborted).toBe(true); + + // The stopped turn resolves without throwing and without an error. + const result = await pending; + expect(result.status).toBe('suspended'); + expect(result.error).toBe(null); + }); + + it('a parent suspend cancels an in-flight sub-agent model call', async () => { + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + + const parent = await customCreateAiRequest({ + userRequest: 'orchestrate', + }); + + // The sub-agent turn's post hangs until its signal aborts. + axios.post.mockImplementationOnce( + (url, body, options) => + new Promise((resolve, reject) => { + options.signal.addEventListener('abort', () => + reject(new Error('canceled')) + ); + }) + ); + const subAgentPending = customCreateSubAgentAiRequest({ + parentAiRequestId: parent.id, + roleId: 'developer', + userRequest: 'do the work', + }); + await waitFor(() => axios.post.mock.calls.length > 1); + + const subAgentSignal = axios.post.mock.calls[1][2].signal; + customSuspendAiRequest(parent.id); + expect(subAgentSignal.aborted).toBe(true); + await expect(subAgentPending).rejects.toThrow('AI request was aborted.'); + }); + }); + + describe('Request Store & Lifecycle Methods', () => { + it('creates, retrieves, and updates local AI requests', async () => { + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [ + { + message: { + role: 'assistant', + content: 'I have processed your request.', + }, + }, + ], + }, + }); + + const aiRequest = await customCreateAiRequest({ + userRequest: 'Hello test AI', + gameProjectJson: null, + projectSpecificExtensionsSummaryJson: null, + mode: 'chat', + aiConfiguration: { presetId: 'default' }, + gameId: null, + }); + + expect(aiRequest.id).toMatch(/^local-ai-/); + expect(aiRequest.status).toBe('ready'); + expect(aiRequest.output.length).toBeGreaterThanOrEqual(2); // user + assistant + + const fetched = await customGetAiRequest(aiRequest.id); + expect(fetched.id).toBe(aiRequest.id); + + const all = await customGetAiRequests(); + expect(all.aiRequests.some(r => r.id === aiRequest.id)).toBe(true); + + const statuses = await customGetAiRequestStatuses([aiRequest.id]); + expect(statuses).toEqual([ + { id: aiRequest.id, status: 'ready', userId: LOCAL_BYOK_USER_ID }, + ]); + }); + + it('suspends an active AI request', async () => { + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [{ message: { role: 'assistant', content: 'Working...' } }], + }, + }); + + const aiRequest = await customCreateAiRequest({ + userRequest: 'Test suspend', + gameProjectJson: null, + projectSpecificExtensionsSummaryJson: null, + mode: 'chat', + aiConfiguration: { presetId: 'default' }, + gameId: null, + }); + + const suspended = await customSuspendAiRequest(aiRequest.id); + expect(suspended.status).toBe('suspended'); + }); + + it('forks an AI request up to a message id', async () => { + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [{ message: { role: 'assistant', content: 'Fork me' } }], + }, + }); + + const aiRequest = await customCreateAiRequest({ + userRequest: 'Original thread', + gameProjectJson: null, + projectSpecificExtensionsSummaryJson: null, + mode: 'chat', + aiConfiguration: { presetId: 'default' }, + gameId: null, + }); + + const forked = await customForkAiRequest(aiRequest.id); + expect(forked.id).not.toBe(aiRequest.id); + expect(forked.output.length).toBe(aiRequest.output.length); + }); + + it('keeps a concurrent in-place rewrite of a pre-existing message (plan flip)', async () => { + // The studio flips a plan task to `done` via a gated write while the + // parent's next model turn is running. The turn's output is a snapshot + // from turn start, so keeping its copy silently clobbered the flip + // (same messageId, so the cache-only merge could not recover it). + setCustomEndpointConfig({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'llama3.2', + temperature: 0.7, + }); + const planFor = status => + JSON.stringify({ + success: true, + plan: { + tasks: [{ id: 'design', title: 'D', description: 'd', status }], + }, + }); + customUpdateAiRequest( + ({ + id: 'local-ai-flip', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + userId: LOCAL_BYOK_USER_ID, + status: 'ready', + error: null, + output: [ + { + type: 'function_call_output', + call_id: 'call_plan', + messageId: 'msg-plan', + output: planFor('pending'), + }, + ], + }: any) + ); + + // $FlowFixMe the model call performs the concurrent flip, then answers. + axios.post.mockImplementationOnce(async () => { + const current = customGetAiRequest('local-ai-flip'); + customUpdateAiRequest( + ({ + ...current, + output: (current.output || []).map(m => + m && m.messageId === 'msg-plan' + ? { ...m, output: planFor('done') } + : m + ), + }: any) + ); + return { + status: 200, + data: { + choices: [{ message: { role: 'assistant', content: 'ok' } }], + }, + }; + }); + + const result = await customAddMessageToAiRequest({ + aiRequestId: 'local-ai-flip', + userMessage: 'next', + }); + const planMessage = (result.output || []).find( + m => m && m.messageId === 'msg-plan' + ); + expect(JSON.parse(planMessage.output).plan.tasks[0].status).toBe('done'); + // And the turn's own answer is present. + expect( + (result.output || []).some( + m => + m && + m.role === 'assistant' && + m.messageId && + m.messageId !== 'msg-plan' + ) + ).toBe(true); + }); + + it('survives a hole when a concurrent write lands during a failed turn', async () => { + // The failure path compares each cache-only message against `output` + // (which carries the persisted hole). When every cache message is + // already in `existing`, the `existingIds` check short-circuits and the + // buggy `.some` never runs - so the write must land DURING the turn, + // making the cache message absent from the `existing` snapshot taken at + // entry. That is exactly a suggestion/plan write racing a model answer. + const userMessage = { + type: 'message', + status: 'completed', + role: 'user', + content: [{ type: 'user_request', status: 'completed', text: 'hi' }], + messageId: 'm-1', + }; + customUpdateAiRequest( + ({ + id: 'local-ai-race', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + userId: LOCAL_BYOK_USER_ID, + status: 'ready', + error: null, + output: [null, userMessage], + }: any) + ); + + // $FlowFixMe + axios.post.mockImplementationOnce(async () => { + customUpdateAiRequest( + ({ + ...customGetAiRequest('local-ai-race'), + output: [ + null, + userMessage, + { + type: 'message', + status: 'completed', + role: 'assistant', + content: [{ type: 'output_text', text: 'suggestion write' }], + messageId: 'm-2', + }, + ], + }: any) + ); + // A rejection with no reason: not a literal, so eslint's + // no-throw-literal does not require a suppression. + const noReason: any = undefined; + throw noReason; + }); + + const result = await customAddMessageToAiRequest({ + aiRequestId: 'local-ai-race', + userMessage: 'next', + }); + expect(result.status).toBe('error'); + // The concurrent message survived the failed turn. + expect( + result.output.some(message => message && message.messageId === 'm-2') + ).toBe(true); + }); + + it('adds a turn when the persisted output has a null hole', async () => { + // `output` starts as a copy of the persisted array, and the merge reads + // `message.messageId` over it - a hole crashed the whole turn on both + // the success (turnIds) and failure (existingIds) paths. + const seed = (output: Array) => { + customUpdateAiRequest( + ({ + id: 'local-ai-hole-turn', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + userId: LOCAL_BYOK_USER_ID, + status: 'ready', + error: null, + output, + }: any) + ); + }; + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [{ message: { role: 'assistant', content: 'ok' } }], + }, + }); + seed([ + null, + { + type: 'message', + status: 'completed', + role: 'user', + content: [{ type: 'user_request', status: 'completed', text: 'hi' }], + messageId: 'm-1', + }, + ]); + + const result = await customAddMessageToAiRequest({ + aiRequestId: 'local-ai-hole-turn', + userMessage: 'next', + }); + expect(result.status).not.toBe('error'); + + // And the failure path: the model rejects, the handler still returns. + // The function-call outputs are network-supplied and may carry a hole. + // $FlowFixMe + axios.post.mockRejectedValueOnce(undefined); + await expect( + customAddMessageToAiRequest({ + aiRequestId: 'local-ai-hole-turn', + functionCallOutputs: ([null, { call_id: 'c1', output: '{}' }]: any), + }) + ).resolves.toBeTruthy(); + }); + + it('actually slices at the given message id (and tolerates a null hole)', async () => { + // The branch this test name promises was never exercised: the sibling + // test passes no id, so findIndex-with-an-id (and the null hole a + // persisted output can carry) was untested - a null entry threw + // 'Cannot read properties of null (reading messageId)' on fork. + const makeMessage = id => ({ + type: 'message', + status: 'completed', + role: 'user', + content: [{ type: 'user_request', status: 'completed', text: id }], + messageId: id, + }); + customUpdateAiRequest( + ({ + id: 'local-ai-fork-src', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + userId: LOCAL_BYOK_USER_ID, + status: 'ready', + error: null, + output: [makeMessage('m1'), null, makeMessage('m3')], + }: any) + ); + + const forked = customForkAiRequest('local-ai-fork-src', 'm3'); + expect(forked.output).toHaveLength(3); + + const sliced = customForkAiRequest('local-ai-fork-src', 'm1'); + expect(sliced.output).toHaveLength(1); + expect(sliced.output[0].messageId).toBe('m1'); + }); + + it('clears the source error and copies the model override on fork', async () => { + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [{ message: { role: 'assistant', content: 'Seed' } }], + }, + }); + const aiRequest = await customCreateAiRequest({ + userRequest: 'Errored parent', + }); + customSetAiRequestModelOverride(aiRequest.id, 'deepseek-chat'); + customUpdateAiRequest({ + ...customGetAiRequest(aiRequest.id), + status: 'error', + error: { code: 'server_error', message: 'boom' }, + }); + + const forked = customForkAiRequest(aiRequest.id); + expect(forked.id).not.toBe(aiRequest.id); + expect(forked.status).toBe('ready'); + expect(forked.error).toBeNull(); + expect(customGetAiRequestModelOverride(forked.id)).toBe('deepseek-chat'); + expect(customGetAiRequestModelOverride(aiRequest.id)).toBe( + 'deepseek-chat' + ); + }); + + it('patches title and archived attributes on a local request', async () => { + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [{ message: { role: 'assistant', content: 'Patch me' } }], + }, + }); + const aiRequest = await customCreateAiRequest({ + userRequest: 'Patch target', + gameProjectJson: null, + projectSpecificExtensionsSummaryJson: null, + mode: 'chat', + aiConfiguration: { presetId: 'default' }, + gameId: null, + }); + + const renamed = customPatchAiRequestAttributes(aiRequest.id, { + title: 'My chat', + }); + expect(renamed.title).toBe('My chat'); + expect(renamed.archivedAt).toBeUndefined(); + expect(customGetAiRequest(aiRequest.id).title).toBe('My chat'); + + const archived = customPatchAiRequestAttributes(aiRequest.id, { + archived: true, + }); + expect(archived.archivedAt).toBeTruthy(); + expect(customGetAiRequest(aiRequest.id).archivedAt).toBeTruthy(); + + const restored = customPatchAiRequestAttributes(aiRequest.id, { + archived: false, + }); + expect(restored.archivedAt).toBeNull(); + }); + + it('returns a fallback when patching an unknown local request', () => { + const fallback = customPatchAiRequestAttributes('local-ai-missing', { + title: 'Nope', + }); + expect(fallback.id).toBe('local-ai-missing'); + expect(fallback.title).toBeUndefined(); + }); + + it('deletes a local request and drops its per-request registries', async () => { + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [{ message: { role: 'assistant', content: 'Delete me' } }], + }, + }); + const aiRequest = await customCreateAiRequest({ + userRequest: 'Delete target', + gameProjectJson: null, + projectSpecificExtensionsSummaryJson: null, + mode: 'chat', + aiConfiguration: { presetId: 'default' }, + gameId: null, + }); + customSetAiRequestModelOverride(aiRequest.id, 'llama3'); + expect( + customGetAiRequests().aiRequests.some(r => r.id === aiRequest.id) + ).toBe(true); + + customDeleteAiRequest(aiRequest.id); + + expect( + customGetAiRequests().aiRequests.some(r => r.id === aiRequest.id) + ).toBe(false); + expect(customGetAiRequestModelOverride(aiRequest.id)).toBe(''); + // Missing id still safe. + expect(() => customDeleteAiRequest('local-ai-missing')).not.toThrow(); + }); + + it('persists a failed first turn as status error (does not throw)', async () => { + axios.post.mockRejectedValueOnce(new Error('ollama down')); + + const failed = await customCreateAiRequest({ + userRequest: 'First message while offline', + gameProjectJson: null, + projectSpecificExtensionsSummaryJson: null, + mode: 'chat', + aiConfiguration: { presetId: 'default' }, + gameId: null, + }); + + expect(failed.id).toMatch(/^local-ai-/); + expect(failed.status).toBe('error'); + expect(failed.error && failed.error.message).toMatch(/ollama down/); + const last = (failed.output || [])[(failed.output || []).length - 1]; + expect(last.role).toBe('user'); + expect(JSON.stringify(last.content)).toContain( + 'First message while offline' + ); + expect(customGetAiRequest(failed.id).status).toBe('error'); + // Cached so Retry can continue the turn. + expect( + customGetAiRequests().aiRequests.some(r => r.id === failed.id) + ).toBe(true); + // A failed first turn never reaches the model response, so it must not + // invent response tokens. + expect(customGetAiRequestTokenTotal(failed.id)).toBe(0); + }); + + it('records first-turn token usage on a successful create', async () => { + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [ + { message: { role: 'assistant', content: 'First reply tokens' } }, + ], + }, + }); + const created = await customCreateAiRequest({ + userRequest: 'Count my first turn tokens', + }); + expect(created.status).toBe('ready'); + expect(customGetAiRequestTokenTotal(created.id)).toBeGreaterThan(0); + }); + + it('continues the first turn after a failed create', async () => { + axios.post.mockRejectedValueOnce(new Error('cold start')); + const failed = await customCreateAiRequest({ + userRequest: 'Seed then recover', + }); + expect(failed.status).toBe('error'); + + axios.post.mockClear(); + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [{ message: { role: 'assistant', content: 'ready now' } }], + }, + }); + const retried = await customRetryAiRequest(failed.id); + expect(axios.post).toHaveBeenCalledTimes(1); + expect(retried.status).toBe('ready'); + expect(retried.error).toBeNull(); + expect(retried.retriesInARowCount).toBe(1); + const last = (retried.output || [])[(retried.output || []).length - 1]; + expect(last.role).toBe('assistant'); + // $FlowFixMe + expect(JSON.stringify(last.content)).toContain('ready now'); + }); + + it('retries a failed local request by re-invoking the model turn', async () => { + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [{ message: { role: 'assistant', content: 'Seed reply' } }], + }, + }); + const aiRequest = await customCreateAiRequest({ + userRequest: 'Retry target', + gameProjectJson: null, + projectSpecificExtensionsSummaryJson: null, + mode: 'chat', + aiConfiguration: { presetId: 'default' }, + gameId: null, + }); + + // Force a terminal error like the local loop would. + customUpdateAiRequest({ + ...customGetAiRequest(aiRequest.id), + status: 'error', + error: { code: 'server_error', message: 'boom' }, + }); + const outputBeforeRetry = (customGetAiRequest(aiRequest.id).output || []) + .length; + + // Continue turn (no new user message) must hit the model once. + axios.post.mockClear(); + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [ + { message: { role: 'assistant', content: 'Fail then retry' } }, + ], + }, + }); + + const retried = await customRetryAiRequest(aiRequest.id); + expect(axios.post).toHaveBeenCalledTimes(1); + expect(retried.status).toBe('ready'); + expect(retried.error).toBeNull(); + expect(retried.retriesInARowCount).toBe(1); + expect(retried.retriedAfterMessagesCount).toBe(outputBeforeRetry); + expect((retried.output || []).length).toBe(outputBeforeRetry + 1); + const lastMessage = (retried.output || [])[ + (retried.output || []).length - 1 + ]; + // $FlowFixMe + expect(lastMessage.role).toBe('assistant'); + expect(customGetAiRequest(aiRequest.id).status).toBe('ready'); + + // Retrying a non-errored request is a no-op (no second model call). + axios.post.mockClear(); + const again = await customRetryAiRequest(aiRequest.id); + expect(axios.post).not.toHaveBeenCalled(); + expect(again.retriesInARowCount).toBe(1); + }); + + it('keeps the request in error when the continue turn fails again', async () => { + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [{ message: { role: 'assistant', content: 'Seed reply' } }], + }, + }); + const aiRequest = await customCreateAiRequest({ + userRequest: 'Retry fail target', + gameProjectJson: null, + projectSpecificExtensionsSummaryJson: null, + mode: 'chat', + aiConfiguration: { presetId: 'default' }, + gameId: null, + }); + customUpdateAiRequest({ + ...customGetAiRequest(aiRequest.id), + status: 'error', + error: { code: 'server_error', message: 'boom' }, + }); + + axios.post.mockClear(); + // $FlowFixMe + axios.post.mockRejectedValueOnce(new Error('endpoint down')); + + const retried = await customRetryAiRequest(aiRequest.id); + expect(axios.post).toHaveBeenCalledTimes(1); + expect(retried.status).toBe('error'); + expect(retried.error && retried.error.message).toMatch(/endpoint down/); + expect(retried.retriesInARowCount).toBe(1); + expect(customGetAiRequest(aiRequest.id).status).toBe('error'); + }); + + it('flips status to error (does not throw) when a model turn fails', async () => { + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [{ message: { role: 'assistant', content: 'Seed reply' } }], + }, + }); + const aiRequest = await customCreateAiRequest({ + userRequest: 'Turn failure target', + gameProjectJson: null, + projectSpecificExtensionsSummaryJson: null, + mode: 'chat', + aiConfiguration: { presetId: 'default' }, + gameId: null, + }); + const outputBefore = (aiRequest.output || []).length; + + axios.post.mockClear(); + axios.post.mockRejectedValueOnce(new Error('endpoint down')); + + const failed = await customAddMessageToAiRequest({ + aiRequestId: aiRequest.id, + userMessage: 'please continue', + }); + + expect(failed.status).toBe('error'); + expect(failed.error && failed.error.message).toMatch(/endpoint down/); + // User message is persisted so Retry → continue has the transcript. + const last = (failed.output || [])[(failed.output || []).length - 1]; + expect(last.role).toBe('user'); + expect(JSON.stringify(last.content)).toContain('please continue'); + expect((failed.output || []).length).toBe(outputBefore + 1); + expect(customGetAiRequest(aiRequest.id).status).toBe('error'); + // Does not throw: container can updateAiRequest from the return value. + await expect( + customAddMessageToAiRequest({ + aiRequestId: aiRequest.id, + userMessage: 'second try', + }).then(async result => result) + ).resolves.toBeTruthy(); + }); + + it('offers continue-turn retry after a failed ordinary turn', async () => { + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [{ message: { role: 'assistant', content: 'Seed reply' } }], + }, + }); + const aiRequest = await customCreateAiRequest({ + userRequest: 'Retry after ordinary failure', + }); + + axios.post.mockClear(); + axios.post.mockRejectedValueOnce(new Error('connection reset')); + const failed = await customAddMessageToAiRequest({ + aiRequestId: aiRequest.id, + userMessage: 'hit the model', + }); + expect(failed.status).toBe('error'); + + // Retry continues the turn (no new user message) and succeeds. + axios.post.mockClear(); + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [{ message: { role: 'assistant', content: 'recovered' } }], + }, + }); + const retried = await customRetryAiRequest(aiRequest.id); + expect(axios.post).toHaveBeenCalledTimes(1); + expect(retried.status).toBe('ready'); + expect(retried.error).toBeNull(); + expect(retried.retriesInARowCount).toBe(1); + const last = (retried.output || [])[(retried.output || []).length - 1]; + expect(last.role).toBe('assistant'); + // $FlowFixMe + expect(JSON.stringify(last.content)).toContain('recovered'); + }); + + it('clears a prior terminal error on a later successful ordinary turn', async () => { + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [{ message: { role: 'assistant', content: 'Seed reply' } }], + }, + }); + const aiRequest = await customCreateAiRequest({ + userRequest: 'Stale error clear', + }); + + axios.post.mockClear(); + axios.post.mockRejectedValueOnce(new Error('endpoint down')); + const failed = await customAddMessageToAiRequest({ + aiRequestId: aiRequest.id, + userMessage: 'hit the model', + }); + expect(failed.status).toBe('error'); + expect(failed.error && failed.error.message).toMatch(/endpoint down/); + + // A later ordinary send (not /action/retry) must drop the terminal + // error: status-agnostic readers (e.g. buildSubAgentReport) must not + // see a false failure after the chat recovered. + axios.post.mockClear(); + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [{ message: { role: 'assistant', content: 'recovered' } }], + }, + }); + const recovered = await customAddMessageToAiRequest({ + aiRequestId: aiRequest.id, + userMessage: 'try again', + }); + expect(recovered.status).toBe('ready'); + expect(recovered.error).toBeNull(); + expect(customGetAiRequest(aiRequest.id).error).toBeNull(); + expect(customGetAiRequest(aiRequest.id).status).toBe('ready'); + }); + + it('persists function-call outputs on a failed mid-turn so continue can re-send them', async () => { + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [{ message: { role: 'assistant', content: 'Seed reply' } }], + }, + }); + const aiRequest = await customCreateAiRequest({ + userRequest: 'FC mid-turn failure', + gameProjectJson: null, + projectSpecificExtensionsSummaryJson: null, + mode: 'chat', + aiConfiguration: { presetId: 'default' }, + gameId: null, + }); + + axios.post.mockClear(); + axios.post.mockRejectedValueOnce(new Error('ollama dropped connection')); + const failed = await customAddMessageToAiRequest({ + aiRequestId: aiRequest.id, + functionCallOutputs: [ + { type: 'function_call_output', call_id: 'call-1', output: '{}' }, + ], + }); + + expect(failed.status).toBe('error'); + // Containers must see the FC outputs still on the request so they do not + // clearEditorFunctionCallResults on a failed send (Retry → continue path). + const fco = (failed.output || []).filter( + message => message.type === 'function_call_output' + ); + expect(fco.length).toBeGreaterThanOrEqual(1); + // $FlowFixMe + expect(fco[fco.length - 1].call_id).toBe('call-1'); + expect(isFailedAiRequestStart(failed)).toBe(true); + expect(customGetAiRequest(aiRequest.id).status).toBe('error'); + }); + + it('returns a fallback when retrying an unknown local request', async () => { + const fallback = await customRetryAiRequest('local-ai-missing'); + expect(fallback.id).toBe('local-ai-missing'); + expect(fallback.status).toBe('ready'); + expect(axios.post).not.toHaveBeenCalled(); + }); + + it('returns top-level suggestions for an unknown local request', async () => { + const suggestions = await customGetAiRequestSuggestions('local-ai-123'); + expect(suggestions.suggestions).toHaveLength(3); + expect(suggestions.suggestions[0].suggestedMessage).toBeDefined(); + // Nothing to attach to — must not invent a cache entry. + expect( + customGetAiRequests().aiRequests.some(r => r.id === 'local-ai-123') + ).toBe(false); + }); + + it('attaches model suggestions onto the last assistant message and returns that snapshot', async () => { + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [{ message: { role: 'assistant', content: 'Seed reply' } }], + }, + }); + const aiRequest = await customCreateAiRequest({ + userRequest: 'Seed for suggestions', + gameProjectJson: null, + projectSpecificExtensionsSummaryJson: null, + mode: 'agent', + aiConfiguration: { presetId: 'default' }, + gameId: null, + }); + + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [ + { + message: { + role: 'assistant', + content: JSON.stringify({ + explanationMessage: 'Try this next:', + suggestions: [ + { title: 'Jump', suggestedMessage: 'Add a jump action' }, + ], + }), + }, + }, + ], + }, + }); + + const result = await customGetAiRequestSuggestions(aiRequest.id); + + // Return value must carry the updated output (callers merge it back). + const lastFromResult = result.output[result.output.length - 1]; + expect(lastFromResult.suggestions).toBeTruthy(); + expect(lastFromResult.suggestions.suggestions[0].title).toBe('Jump'); + + // Cache must match the return value so a subsequent merge cannot clobber. + const cached = customGetAiRequest(aiRequest.id); + const lastFromCache = cached.output[cached.output.length - 1]; + expect(lastFromCache.suggestions).toBeTruthy(); + expect(lastFromCache.suggestions.suggestions[0].title).toBe('Jump'); + }); + + it('budgets the suggestion prompt to the model context window', async () => { + // The suggestions call replayed the full history with no trim: on a + // small local model the request went over-window, errored, and fell + // back to defaults after burning the request. + setCustomEndpointConfig({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'llama3.2', + temperature: 0.7, + }); + // $FlowFixMe + axios.post.mockResolvedValue({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + const aiRequest = await customCreateAiRequest({ + userRequest: 'Seed for suggestions', + gameProjectJson: null, + projectSpecificExtensionsSummaryJson: null, + mode: 'agent', + aiConfiguration: { presetId: 'default' }, + gameId: null, + }); + await customAddMessageToAiRequest({ + aiRequestId: aiRequest.id, + userMessage: 'Big context. '.repeat(1500), + }); + + axios.post.mockClear(); + // $FlowFixMe + axios.post.mockResolvedValue({ + status: 200, + data: { + choices: [ + { + message: { + role: 'assistant', + content: JSON.stringify({ + explanationMessage: 'Try this next:', + suggestions: [ + { title: 'Jump', suggestedMessage: 'Add a jump action' }, + ], + }), + }, + }, + ], + }, + }); + await customGetAiRequestSuggestions(aiRequest.id); + + const sentMessages = axios.post.mock.calls[0][1].messages; + // llama3.2 input budget is 4096; the plain suggestions call sends no + // tools, so the messages alone must fit it. + expect(estimateMessagesTokens(sentMessages)).toBeLessThanOrEqual(4096); + }); + + it('attaches defaults when the persisted output has a null hole', async () => { + // The loader only checks id/status and normalize passes holes through, + // so output[0] can be null - and reading lastMsg.type then threw out + // of both the try and the catch path, losing suggestions entirely. + const memoryStorage = {}; + const fakeStorage = { + getItem: key => (key in memoryStorage ? memoryStorage[key] : null), + setItem: (key, value) => { + memoryStorage[key] = String(value); + }, + removeItem: key => { + delete memoryStorage[key]; + }, + }; + global.localStorage = fakeStorage; + try { + _resetCustomAiClientForTesting(); + setCustomEndpointConfig({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'llama3.2', + temperature: 0.7, + }); + fakeStorage.setItem( + 'gd-custom-ai-requests', + JSON.stringify({ + 'local-ai-hole': { + id: 'local-ai-hole', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + userId: LOCAL_BYOK_USER_ID, + status: 'ready', + error: null, + output: [null], + }, + }) + ); + expect(loadLocalAiRequests()['local-ai-hole']).toBeTruthy(); + // $FlowFixMe + axios.post.mockResolvedValue({ + status: 200, + data: { + choices: [{ message: { role: 'assistant', content: 'ok' } }], + }, + }); + + const result = await customGetAiRequestSuggestions('local-ai-hole'); + expect(result.suggestions).toHaveLength(3); + } finally { + delete global.localStorage; + _resetCustomAiClientForTesting(); + } + }); + + it('returns null from sanitizeAiRequestSuggestions for wrong-shape payloads', () => { + expect(sanitizeAiRequestSuggestions(null)).toBe(null); + expect(sanitizeAiRequestSuggestions('nope')).toBe(null); + expect( + sanitizeAiRequestSuggestions({ suggestions: 'not-an-array' }) + ).toBe(null); + expect(sanitizeAiRequestSuggestions({ suggestions: [] })).toBe(null); + expect( + sanitizeAiRequestSuggestions({ + suggestions: [{ title: 'Only title' }], + }) + ).toBe(null); + expect( + sanitizeAiRequestSuggestions({ + explanationMessage: 'Next:', + suggestions: [{ title: 'T', suggestedMessage: 42 }], + }) + ).toBe(null); + }); + + it('passes through well-formed suggestions from sanitizeAiRequestSuggestions', () => { + const result = sanitizeAiRequestSuggestions({ + explanationMessage: ' Try this: ', + suggestions: [ + { title: ' Jump ', suggestedMessage: 'Add a jump action' }, + ], + }); + expect(result).not.toBe(null); + if (!result) return; + expect(result.explanationMessage).toBe('Try this:'); + expect(result.suggestions).toHaveLength(1); + expect(result.suggestions[0].title).toBe('Jump'); + expect(result.suggestions[0].suggestedMessage).toBe('Add a jump action'); + }); + + it('falls back to default suggestions when the model returns wrong-shape JSON', async () => { + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [{ message: { role: 'assistant', content: 'Seed reply' } }], + }, + }); + const aiRequest = await customCreateAiRequest({ + userRequest: 'Seed for wrong-shape suggestions', + gameProjectJson: null, + projectSpecificExtensionsSummaryJson: null, + mode: 'agent', + aiConfiguration: { presetId: 'default' }, + gameId: null, + }); + + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [ + { + message: { + role: 'assistant', + content: JSON.stringify({ + explanationMessage: 'Try this next:', + suggestions: 'not-an-array', + }), + }, + }, + ], + }, + }); + + const result = await customGetAiRequestSuggestions(aiRequest.id); + expect(result.suggestions).toHaveLength(3); + const lastFromResult = result.output[result.output.length - 1]; + expect(lastFromResult.suggestions).toBeTruthy(); + expect(Array.isArray(lastFromResult.suggestions.suggestions)).toBe(true); + expect(lastFromResult.suggestions.suggestions).toHaveLength(3); + + const cached = customGetAiRequest(aiRequest.id); + const lastFromCache = cached.output[cached.output.length - 1]; + expect(lastFromCache.suggestions).toBeTruthy(); + expect(lastFromCache.suggestions.suggestions).toHaveLength(3); + }); + + it('falls back to default suggestions on the last message when the model returns malformed JSON', async () => { + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [{ message: { role: 'assistant', content: 'Seed reply' } }], + }, + }); + const aiRequest = await customCreateAiRequest({ + userRequest: 'Seed for fallback suggestions', + gameProjectJson: null, + projectSpecificExtensionsSummaryJson: null, + mode: 'agent', + aiConfiguration: { presetId: 'default' }, + gameId: null, + }); + + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [{ message: { role: 'assistant', content: 'not-json{{{' } }], + }, + }); + + const result = await customGetAiRequestSuggestions(aiRequest.id); + expect(result.suggestions).toHaveLength(3); + const lastFromResult = result.output[result.output.length - 1]; + expect(lastFromResult.suggestions).toBeTruthy(); + expect(lastFromResult.suggestions.suggestions).toHaveLength(3); + + const cached = customGetAiRequest(aiRequest.id); + const lastFromCache = cached.output[cached.output.length - 1]; + expect(lastFromCache.suggestions).toBeTruthy(); + }); + + it('generates event changes with customCreateAiGeneratedEvent', async () => { + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [ + { + message: { + role: 'assistant', + content: + '```json\n[{"type":"add_events","events":[{"type":"BuiltinCommonInstructions::Standard","conditions":[],"actions":[]}]}]\n```', + }, + }, + ], + }, + }); + + const result = await customCreateAiGeneratedEvent({ + sceneName: 'MainScene', + eventsDescription: 'Move player right when key pressed', + eventBatches: null, + extensionNamesList: '', + objectsList: 'Player', + existingEventsAsText: '', + }); + + expect(result.creationSucceeded).toBe(true); + if (result.creationSucceeded) { + expect(result.aiGeneratedEvent.changes.length).toBeGreaterThan(0); + } + }); + + it('compacts oversized existing events to the model context budget', async () => { + // A big scene's event text alone can exceed a small local model's + // window: sent verbatim, the request fails over-window and the whole + // generation is lost. The existing events are compacted to fit. + setCustomEndpointConfig({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'llama3.2', + temperature: 0.7, + }); + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [ + { + message: { + role: 'assistant', + content: JSON.stringify({ + operationName: 'insert', + generatedEvents: '[]', + }), + }, + }, + ], + }, + }); + + const result = await customCreateAiGeneratedEvent({ + sceneName: 'MainScene', + eventsDescription: 'Move player right when key pressed', + eventBatches: null, + extensionNamesList: '', + objectsList: 'Player', + existingEventsAsText: 'Condition: key pressed. Action: move.\n'.repeat( + 3000 + ), + }); + + expect(result.creationSucceeded).toBe(true); + const sentContent = axios.post.mock.calls[0][1].messages[1].content; + // llama3.2 input budget is 4096 tokens for the whole prompt. + expect( + estimateMessagesTokens([{ role: 'user', content: sentContent }]) + ).toBeLessThanOrEqual(4096); + expect(sentContent).toContain('truncated to fit the model'); + }); + + it('sends normal-size existing events untouched', async () => { + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [ + { + message: { + role: 'assistant', + content: JSON.stringify({ + operationName: 'insert', + generatedEvents: '[]', + }), + }, + }, + ], + }, + }); + + await customCreateAiGeneratedEvent({ + sceneName: 'MainScene', + eventsDescription: 'Move player right when key pressed', + eventBatches: null, + extensionNamesList: '', + objectsList: 'Player', + existingEventsAsText: 'Condition: key pressed.', + }); + + const sentContent = axios.post.mock.calls[0][1].messages[1].content; + expect(sentContent).toContain('Condition: key pressed.'); + expect(sentContent).not.toContain('truncat'); + }); + + it('coerces malformed array/object fields instead of passing them through', async () => { + // The array-typed fields are model-authored, and the editor calls + // `.join` on `diagnosticLines` and iterates `extensionNames`. A truthy + // non-array (a string, a number) used to pass through `X || []` and threw + // in the consumer, losing the whole generation result. + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [ + { + message: { + role: 'assistant', + content: JSON.stringify({ + operationName: 'insert', + generatedEvents: '[]', + diagnosticLines: 'a single line, not an array', + extensionNames: 'ExtensionA', + undeclaredVariables: 42, + undeclaredObjectVariables: 'nope', + missingObjectBehaviors: ['an', 'array'], + missingResources: true, + }), + }, + }, + ], + }, + }); + + const result = await customCreateAiGeneratedEvent({ + sceneName: 'MainScene', + eventsDescription: 'Do something', + }); + + expect(result.creationSucceeded).toBe(true); + if (result.creationSucceeded) { + const [change]: any = result.aiGeneratedEvent.changes; + expect(change.diagnosticLines).toEqual([]); + expect(change.extensionNames).toEqual([]); + expect(change.undeclaredVariables).toEqual([]); + expect(change.undeclaredObjectVariables).toEqual({}); + expect(change.missingObjectBehaviors).toEqual({}); + expect(change.missingResources).toEqual([]); + // The consumer's call must not throw. + expect(() => change.diagnosticLines.join('\n')).not.toThrow(); + } + }); + + it('fails with a clear error when the model response is not JSON', async () => { + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [ + { + message: { + role: 'assistant', + content: 'Sorry, I cannot help with that request.', + }, + }, + ], + }, + }); + + const result = await customCreateAiGeneratedEvent({ + sceneName: 'MainScene', + eventsDescription: 'Move player right', + }); + + expect(result.creationSucceeded).toBe(false); + if (!result.creationSucceeded) { + expect(result.errorMessage).toContain('could not be parsed as JSON'); + } + }); + + it('fails when generatedEvents is not a JSON list of events', async () => { + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [ + { + message: { + role: 'assistant', + content: + '```json\n{"operationName":"insert","generatedEvents":"not-a-json-array"}\n```', + }, + }, + ], + }, + }); + + const result = await customCreateAiGeneratedEvent({ + sceneName: 'MainScene', + eventsDescription: 'Move player right', + }); + + expect(result.creationSucceeded).toBe(false); + if (!result.creationSucceeded) { + expect(result.errorMessage).toContain('generatedEvents'); + } + }); + + it('accepts a generatedEvents JSON string array payload', async () => { + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [ + { + message: { + role: 'assistant', + content: + '```json\n{"operationName":"insert","generatedEvents":' + + JSON.stringify( + JSON.stringify([ + { type: 'BuiltinCommonInstructions::Standard' }, + ]) + ) + + '}\n```', + }, + }, + ], + }, + }); + + const result = await customCreateAiGeneratedEvent({ + sceneName: 'MainScene', + eventsDescription: 'Move player right', + }); + + expect(result.creationSucceeded).toBe(true); + if (result.creationSucceeded) { + const change = result.aiGeneratedEvent.changes[0]; + expect(JSON.parse(change.generatedEvents)).toEqual([ + { type: 'BuiltinCommonInstructions::Standard' }, + ]); + } + }); + + it('provides empty asset and resource search results in BYOK mode', async () => { + const assetResult = await customCreateAssetSearch({ + searchTerms: 'coin', + objectType: 'Sprite', + }); + expect(assetResult.id).toMatch(/^local-asset-/); + expect(assetResult.userId).toBe(LOCAL_BYOK_USER_ID); + expect(assetResult.results).toEqual([]); + + const resourceResult = await customCreateResourceSearch({ + searchTerms: 'jump sound', + resourceKind: 'audio', + }); + expect(resourceResult.id).toMatch(/^local-resource-/); + expect(resourceResult.userId).toBe(LOCAL_BYOK_USER_ID); + expect(resourceResult.results).toEqual([]); + }); + }); + + describe('Concurrent local model turns', () => { + /** A completion that answers after `delayMs` with a distinct message. */ + const answerAfter = (content: string, delayMs: number) => { + axios.post.mockImplementationOnce( + () => + new Promise(resolve => + setTimeout( + () => + resolve({ + status: 200, + data: { + choices: [{ message: { role: 'assistant', content } }], + }, + }), + delayMs + ) + ) + ); + }; + + const createRequest = async (userRequest: string) => { + answerAfter('ok', 0); + return customCreateAiRequest({ + userRequest, + gameProjectJson: null, + projectSpecificExtensionsSummaryJson: null, + mode: 'agent', + aiConfiguration: { presetId: 'default' }, + gameId: null, + }); + }; + + it('serializes two concurrent turns on one request without losing a write', async () => { + const aiRequest = await createRequest('Start'); + + // The first turn is slow; the second starts while it is still answering. + answerAfter('first answer', 20); + const firstTurn = customAddMessageToAiRequest({ + aiRequestId: aiRequest.id, + userMessage: '', + functionCallOutputs: [ + { type: 'function_call_output', call_id: 'call-1', output: '{}' }, + ], + }); + answerAfter('second answer', 0); + const secondTurn = customAddMessageToAiRequest({ + aiRequestId: aiRequest.id, + userMessage: '', + functionCallOutputs: [ + { type: 'function_call_output', call_id: 'call-2', output: '{}' }, + ], + }); + + const [firstResult, secondResult] = await Promise.all([ + firstTurn, + secondTurn, + ]); + + const finalRequest = await customGetAiRequest(aiRequest.id); + const assistantTexts = (finalRequest.output || []) + .filter( + message => message.type === 'message' && message.role === 'assistant' + ) + .map(message => (message: any).text || ''); + + // Both answers are present: the second turn did not overwrite the first. + expect(assistantTexts).toContain('first answer'); + expect(assistantTexts).toContain('second answer'); + // Both tool results are present too. + const outputCallIds = (finalRequest.output || []) + .filter(message => message.type === 'function_call_output') + .map(message => (message: any).call_id); + expect(outputCallIds).toEqual( + expect.arrayContaining(['call-1', 'call-2']) + ); + // Each turn's own return value carries its own answer. + expect( + firstResult.output ? firstResult.output.length : 0 + ).toBeGreaterThan(0); + expect( + secondResult.output ? secondResult.output.length : 0 + ).toBeGreaterThan(0); + }); + + it('does not block concurrent turns on different requests', async () => { + const firstRequest = await createRequest('One'); + const secondRequest = await createRequest('Two'); + + answerAfter('slow answer', 20); + const slowTurn = customAddMessageToAiRequest({ + aiRequestId: firstRequest.id, + userMessage: '', + functionCallOutputs: [], + }); + answerAfter('fast answer', 0); + const fastTurn = customAddMessageToAiRequest({ + aiRequestId: secondRequest.id, + userMessage: '', + functionCallOutputs: [], + }); + + // The fast one resolves while the slow one is still waiting. + await fastTurn; + await slowTurn; + + const first = await customGetAiRequest(firstRequest.id); + const second = await customGetAiRequest(secondRequest.id); + expect(JSON.stringify(first.output || '').includes('slow answer')).toBe( + true + ); + expect(JSON.stringify(second.output || '').includes('fast answer')).toBe( + true + ); + }); + + it('keeps a suspension that lands during an in-flight turn', async () => { + const aiRequest = await createRequest('Suspend me'); + + answerAfter('answer after suspend', 20); + const turn = customAddMessageToAiRequest({ + aiRequestId: aiRequest.id, + userMessage: '', + functionCallOutputs: [], + }); + + // Suspend while the model is answering. + customSuspendAiRequest(aiRequest.id); + await turn; + + const finalRequest = await customGetAiRequest(aiRequest.id); + // The turn write-back must not resume a suspended request. + expect(finalRequest.status).toBe('suspended'); + // The answer the model produced is still readable. + expect(JSON.stringify(finalRequest.output || '')).toContain( + 'answer after suspend' + ); + }); + + it('resumes a stopped request when the user sends another message', async () => { + const aiRequest = await createRequest('Stop then continue'); + customSuspendAiRequest(aiRequest.id); + expect((await customGetAiRequest(aiRequest.id)).status).toBe('suspended'); + + answerAfter('resumed answer', 0); + const resumed = await customAddMessageToAiRequest({ + aiRequestId: aiRequest.id, + userMessage: 'keep going', + functionCallOutputs: [], + }); + + // The explicit send must clear the stop, or RequestWriteGate and + // suggestions stay blocked forever behind "Stopped. Ready when you are." + expect(resumed.status).toBe('ready'); + expect((await customGetAiRequest(aiRequest.id)).status).toBe('ready'); + expect(JSON.stringify(resumed.output || '')).toContain('resumed answer'); + expect(JSON.stringify(resumed.output || '')).toContain('keep going'); + }); + }); + + describe('withLocalAiTurnLock', () => { + it('serializes turns for the same request', async () => { + const order = []; + const first = withLocalAiTurnLock('lock-req-1', async () => { + order.push('first-start'); + await new Promise(resolve => setTimeout(resolve, 20)); + order.push('first-end'); + }); + const second = withLocalAiTurnLock('lock-req-1', async () => { + order.push('second'); + }); + await Promise.all([first, second]); + // The second turn must not start until the first released the lock. + expect(order).toEqual(['first-start', 'first-end', 'second']); + }); + + it('does not block a different request', async () => { + const order = []; + let releaseFirst; + const first = withLocalAiTurnLock('lock-req-2', async () => { + order.push('held-start'); + await new Promise(resolve => { + releaseFirst = resolve; + }); + order.push('held-end'); + }); + // Let the first acquire the lock before queueing the other request. + await new Promise(resolve => setTimeout(resolve, 5)); + const other = withLocalAiTurnLock('lock-req-3', async () => { + order.push('other'); + }); + await other; + expect(order).toEqual(['held-start', 'other']); + releaseFirst(); + await first; + expect(order).toEqual(['held-start', 'other', 'held-end']); + }); + + it('releases the lock when the turn throws, so a queued turn still runs', async () => { + // A turn queued WHILE the throwing one holds the lock is the case that + // deadlocks if the failure path skips `release`: it waits on a tail that + // never settles. + const order = []; + const failing = withLocalAiTurnLock('lock-req-4', async () => { + order.push('failing'); + throw new Error('turn failed'); + }).catch(() => order.push('caught')); + const queued = withLocalAiTurnLock('lock-req-4', async () => { + order.push('queued'); + }); + + const winner = await Promise.race([ + queued.then(() => 'queued-ran'), + new Promise(resolve => setTimeout(() => resolve('timed-out'), 500)), + ]); + await failing; + expect(winner).toBe('queued-ran'); + expect(order).toEqual(['failing', 'caught', 'queued']); + }); + }); + + describe('Studio sub-agent role enforcement', () => { + /** Mock one assistant reply so a turn completes. */ + const mockAssistantReply = (content: string) => { + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content } }] }, + }); + }; + + const spawnTester = async () => { + mockAssistantReply('Spawned.'); + return customCreateSubAgentAiRequest({ + parentAiRequestId: 'local-ai-parent', + roleId: 'tester', + userRequest: 'Verify the grid.', + gameProjectJson: null, + projectSpecificExtensionsSummaryJson: null, + spawnContextNote: null, + }); + }; + + const sentToolNames = (callIndex: number) => { + // $FlowFixMe + const body = axios.post.mock.calls[callIndex][1]; + return { + names: (body.tools || []).map(tool => tool.function.name), + systemPrompt: body.messages[0].content, + }; + }; + + it('offers a spawned sub-agent only its role tools and prompt', async () => { + await spawnTester(); + const { names, systemPrompt } = sentToolNames(0); + const testerToolNames = getToolsForRole( + 'tester', + GDEVELOP_OPENAI_TOOLS + ).map(tool => tool.function.name); + expect(names.length).toBeGreaterThan(0); + names.forEach(name => expect(testerToolNames).toContain(name)); + expect(systemPrompt).toContain('QA tester'); + }); + + it('keeps the role tool subset and prompt on later turns', async () => { + const child = await spawnTester(); + + // A second turn - the critical regression: pre-fix this offered all the + // tools and dropped the role prompt. + mockAssistantReply('Done.'); + await customAddMessageToAiRequest({ + aiRequestId: child.id, + userMessage: '', + functionCallOutputs: [], + }); + + const { names, systemPrompt } = sentToolNames(1); + const testerToolNames = getToolsForRole( + 'tester', + GDEVELOP_OPENAI_TOOLS + ).map(tool => tool.function.name); + expect(names.length).toBeGreaterThan(0); + names.forEach(name => expect(testerToolNames).toContain(name)); + expect(systemPrompt).toContain('QA tester'); + }); + + it('fails closed to the read-only toolset for an unrecognized creation role', async () => { + // getToolsForRole throws on an unknown id, which is deliberate: a role + // that is meant to be read-only must not silently gain mutations. The + // creation path fails closed to the read-only subset instead of throwing + // or handing over every tool. + mockAssistantReply('Spawned.'); + await customCreateSubAgentAiRequest({ + parentAiRequestId: 'local-ai-parent', + roleId: 'not-a-role', + userRequest: 'Do something.', + gameProjectJson: null, + projectSpecificExtensionsSummaryJson: null, + spawnContextNote: null, + }); + + const { names, systemPrompt } = sentToolNames(0); + expect(names.length).toBeGreaterThan(0); + // The contract is the security property, not a specific list: nothing + // offered may be a tool that mutates the project, and everything offered + // is a declared read-only one. + names.forEach(name => expect(MUTATING_TOOL_NAMES).not.toContain(name)); + names.forEach(name => expect(READ_ONLY_TOOL_NAMES).toContain(name)); + // No role prompt is invented for an unknown role. + expect(systemPrompt).not.toContain('QA tester'); + }); + + it('keeps a read-only toolset on later turns of a corrupted-role sub-agent', async () => { + // A persisted sub-agent whose studioRoleId is stale (a role renamed or + // removed) must not throw mid-turn, nor regain the mutating tools on its + // next turn. Seed the local cache the way a reload would. + global.localStorage = { + getItem: key => + key === 'gd-custom-ai-requests' + ? JSON.stringify({ + 'local-ai-child': { + id: 'local-ai-child', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + userId: LOCAL_BYOK_USER_ID, + status: 'ready', + mode: 'agent', + parentAiRequestId: 'local-ai-parent', + studioRoleId: 'legacy-role-that-no-longer-exists', + error: null, + output: [], + }, + }) + : null, + setItem: () => {}, + removeItem: () => {}, + }; + _resetCustomAiClientForTesting(); + loadLocalAiRequests(); + try { + mockAssistantReply('Continued.'); + + // Must not throw, and must not offer a mutating tool. + await customAddMessageToAiRequest({ + aiRequestId: 'local-ai-child', + userMessage: 'keep going', + functionCallOutputs: [], + }); + + const { names } = sentToolNames(0); + expect(names.length).toBeGreaterThan(0); + names.forEach(name => expect(MUTATING_TOOL_NAMES).not.toContain(name)); + expect(names).not.toContain('create_scene'); + expect(names).not.toContain('run_script'); + } finally { + delete global.localStorage; + } + }); + + it('writes a mutated request through to the cache', async () => { + const child = await spawnTester(); + const before = (child.output || []).length; + const marker: any = { + type: 'message', + role: 'assistant', + status: 'completed', + content: [ + { + type: 'output_text', + status: 'completed', + text: 'marker', + annotations: [], + }, + ], + messageId: 'msg-marker', + }; + customUpdateAiRequest({ + ...child, + output: [...(child.output || []), marker], + }); + expect(customGetAiRequest(child.id).output.length).toBe(before + 1); + }); + }); + + describe('local answers use the type the chat renders', () => { + it('emits output_text, not text, for the assistant answer', () => { + // ChatMessages renders only `output_text` and `reasoning`, and + // RenderItem's messageContent union admits only those two. The local + // parser emitted `text`, which hit `return null` — the assistant's own + // answer rendered as nothing at all in a BYOK chat. + const message = parseAssistantMessage({ + role: 'assistant', + content: 'Here is the answer.', + }); + expect(message.content).toHaveLength(1); + expect(message.content[0].type).toBe('output_text'); + expect(message.content[0].text).toBe('Here is the answer.'); + // The union the renderer uses does not include 'text' at all. + expect(message.content.some(item => item.type === 'text')).toBe(false); + }); + + it('keeps the plain-text top-level field as well', () => { + // FinalizeSubAgents falls back to message.text, so it must stay. + const message = parseAssistantMessage({ + role: 'assistant', + content: 'Report body.', + }); + expect(message.text).toBe('Report body.'); + }); + }); + + describe('reasoning is emitted as rendered content', () => { + it('puts the reasoning entry before the answer text', () => { + // The chat renders content in order and the chat UI's `reasoning` + // branch was unreachable: parseAssistantMessage extracted `thinking` + // into a top-level field no component read, so a reasoning model's chain + // of thought was discarded. + const message = parseAssistantMessage({ + role: 'assistant', + content: 'Here is the answer.', + reasoning_content: 'Let me plan the scene.', + }); + expect(message.content).toHaveLength(2); + expect(message.content[0]).toEqual({ + type: 'reasoning', + status: 'completed', + summary: { text: 'Let me plan the scene.', type: 'summary_text' }, + }); + expect(message.content[1]).toEqual({ + // 'output_text' is what ChatMessages renders and what RenderItem + // admits; 'text' was handled by no branch, so the answer vanished. + type: 'output_text', + status: 'completed', + text: 'Here is the answer.', + annotations: [], + }); + }); + + it('reads a think block as reasoning content too', () => { + // Build the tag from char codes: the file's own fixtures do, so the + // literal angle bracket cannot be lost in transit. + const open = String.fromCharCode(60) + 'think>'; + const close = String.fromCharCode(60) + '/think>'; + const message = parseAssistantMessage({ + role: 'assistant', + content: open + 'Working it out.' + close + 'Result.', + }); + expect(message.content[0].type).toBe('reasoning'); + expect(message.content[0].summary.text).toBe('Working it out.'); + expect(message.content[1]).toEqual({ + type: 'output_text', + status: 'completed', + text: 'Result.', + annotations: [], + }); + }); + + it('emits no reasoning entry for a plain answer', () => { + const message = parseAssistantMessage({ + role: 'assistant', + content: 'Just an answer.', + }); + expect( + message.content.filter(item => item.type === 'reasoning') + ).toHaveLength(0); + expect(message.content).toHaveLength(1); + }); + + it('still emits a reasoning entry when the answer is empty', () => { + // A reasoner that stopped after thinking: the thinking is the turn's + // only visible content, so it must not be dropped. + const message = parseAssistantMessage({ + role: 'assistant', + content: '', + reasoning_content: 'Only thinking here.', + }); + expect(message.content).toHaveLength(1); + expect(message.content[0].type).toBe('reasoning'); + }); + }); + + describe('the embedded-JSON fallback tool list', () => { + it('names only tools the registry actually declares', () => { + // Regression: three entries were `describe_*` names that the registry + // does not declare (it calls them read_events_source, + // inspect_scene_properties_layers_effects, inspect_variables), so a + // model that emitted one of those calls as a JSON block got no tool + // executed at all. Every entry must match a declared name exactly. + const declared = new Set( + GDEVELOP_OPENAI_TOOLS.map(tool => tool.function.name) + ); + const missing = [...SIDE_EFFECT_FREE_TOOLS].filter( + name => !declared.has(name) + ); + expect(missing).toEqual([]); + }); + + it('lists no tool that mutates the project', () => { + // The fallback is for calls the model wrote as text, so it must never + // reach a mutating tool. + for (const name of SIDE_EFFECT_FREE_TOOLS) { + expect( + name.startsWith('inspect_') || + name.startsWith('read_') || + name.startsWith('search_') || + name === 'describe_instances' || + name === 'get_game_starter_summary' + ).toBe(true); + } + }); + }); + + describe('the connection test requires a real answer', () => { + it('does not report success when the model answered with no text', async () => { + // A completion with no text (reasoning-only model, or a bare body) + // proves nothing about the endpoint; calling it a successful connection + // sends the user off with a broken setup. + // $FlowFixMe + axios.get.mockRejectedValueOnce(new Error('404')); + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: '' } }] }, + }); + + const result = await testConnection({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'qwen2.5-coder', + temperature: 0.7, + }); + + expect(result.success).toBe(false); + expect(result.message).toContain('no text'); + }); + + it('reports success with the model text when it answers', async () => { + // $FlowFixMe + axios.get.mockRejectedValueOnce(new Error('404')); + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'OK' } }] }, + }); + + const result = await testConnection({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'qwen2.5-coder', + temperature: 0.7, + }); + + expect(result.success).toBe(true); + expect(result.message).toContain('OK'); + }); + + it('returns a failure object when the probe rejects with no reason', async () => { + // $FlowFixMe + axios.get.mockRejectedValueOnce(new Error('404')); + // $FlowFixMe + axios.post.mockRejectedValueOnce(undefined); + + const result = await testConnection({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'qwen2.5-coder', + temperature: 0.7, + }); + + expect(result.success).toBe(false); + // The friendly fallback, not the raw TypeError the unguarded catch + // produced ("Cannot read properties of undefined (reading 'response')"). + expect(result.message).toContain('Connection failed'); + expect(result.message).toContain('AI request failed'); + }); + }); + + describe('a streamed error chunk', () => { + it("fails the turn with the server's own reason", async () => { + // A server can explain a mid-stream failure in an error chunk. Ignoring + // it left only the generic "connection may have been dropped" message + // for a failure the server had already described. + const encoder = new TextEncoder(); + const sse = + 'data: ' + + JSON.stringify({ error: { message: 'model unloaded from memory' } }) + + '\n' + + 'data: [DONE]\n'; + let readCount = 0; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => 'text/event-stream' }, + body: { + getReader: () => ({ + read: async () => { + readCount += 1; + if (readCount === 1) { + return { done: false, value: encoder.encode(sse) }; + } + return { done: true, value: undefined }; + }, + }), + }, + }); + + let thrownMessage = ''; + try { + await sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: { + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'qwen2.5-coder', + temperature: 0.7, + streaming: true, + }, + }); + } catch (error) { + thrownMessage = error.message; + } + + expect(thrownMessage).toContain('model unloaded from memory'); + expect(thrownMessage).not.toContain('dropped mid-stream'); + }); + }); + + describe('a 200 response carrying an error body', () => { + it('fails the turn instead of recording an empty answer', async () => { + // Some servers report model failures with HTTP 200 and an error body. + // Returning it as a message made the turn look successful: the parser + // found no content, so an empty assistant reply was stored and the user + // got neither an error nor a retry. + setCustomEndpointConfig({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'qwen2.5-coder', + temperature: 0.7, + }); + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { error: { message: 'model is loading, try again' } }, + }); + + const created = await customCreateAiRequest({ + userRequest: 'start', + mode: 'chat', + }); + + // The turn is an error, with the server's own message, not a blank reply. + expect(created.status).toBe('error'); + expect(created.error && created.error.message).toContain( + 'model is loading, try again' + ); + const assistantMessages = (created.output || []).filter( + message => message.role === 'assistant' + ); + expect(assistantMessages).toHaveLength(0); + }); + + it('still accepts a normal completion body', async () => { + setCustomEndpointConfig({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'qwen2.5-coder', + temperature: 0.7, + }); + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + const created = await customCreateAiRequest({ + userRequest: 'start', + mode: 'chat', + }); + expect(created.status).toBe('ready'); + }); + }); + + describe('context occupancy is the latest prompt, not the running cost', () => { + it('tracks the most recent prompt size while tokens total keeps growing', async () => { + // The gauge reports how full the window is NOW, so it must read the last + // prompt's size. The cost meter is cumulative — dividing it by a + // per-request budget would inflate every turn and describe nothing. + setCustomEndpointConfig({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'qwen2.5-coder', + temperature: 0.7, + }); + // $FlowFixMe + axios.post.mockResolvedValue({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + + const created = await customCreateAiRequest({ + userRequest: 'start', + mode: 'chat', + }); + const firstPrompt = customGetAiRequestContextTokens(created.id); + const firstTotal = customGetAiRequestTokenTotal(created.id); + expect(firstPrompt).toBeGreaterThan(0); + + await customAddMessageToAiRequest({ + aiRequestId: created.id, + // Long enough to dominate any constant-term shift between turns + // (e.g. the offered toolset shrinking by one entry): the test pins + // that occupancy follows conversation size. + userMessage: + 'a second, much longer user message to grow the prompt. ' + + 'Filler text so the growth dwarfs constant shifts. '.repeat(30), + }); + + const secondPrompt = customGetAiRequestContextTokens(created.id); + const secondTotal = customGetAiRequestTokenTotal(created.id); + + // Occupancy grows with the conversation... + expect(secondPrompt).toBeGreaterThan(firstPrompt); + // ...and remains distinct from the cumulative cost meter, which carries + // every turn's prompt and answer. Using the total as the numerator would + // make the gauge climb forever. + expect(secondTotal).toBeGreaterThan(secondPrompt); + expect(secondTotal).toBeGreaterThan(firstTotal); + }); + + it('is zero for a request with no turns', () => { + expect(customGetAiRequestContextTokens('local-ai-never-ran')).toBe(0); + }); + + it('counts the tool schema sent with the turn, not only the messages', async () => { + // The tool schema (~6.5k tokens) rides every request: a messages-only + // numerator understated occupancy ~2x on small windows, so the gauge + // stayed quiet while the trimmer silently compacted. + setCustomEndpointConfig({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'qwen2.5-coder', + temperature: 0.7, + }); + // $FlowFixMe + axios.post.mockResolvedValue({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + + const created = await customCreateAiRequest({ + userRequest: 'start', + mode: 'chat', + }); + expect( + customGetAiRequestContextTokens(created.id) + ).toBeGreaterThanOrEqual(estimateToolsTokens(GDEVELOP_OPENAI_TOOLS)); + }); + }); + + describe('local cost meter counts reasoning output', () => { + it('bills a streamed chain of thought, not only the answer', async () => { + // Differential: run the same create twice, once with a reasoning delta + // and once without. The prompt (a ~220-token system message) dominates + // the absolute total, so only the *difference* proves reasoning is + // billed — a flat threshold passed with and without the fix. + const runCreate = async (withReasoning: boolean) => { + const encoder = new TextEncoder(); + const reasoningText = 'Reasoning '.repeat(40); // ~100 tokens + const lines = []; + if (withReasoning) { + lines.push( + 'data: ' + + JSON.stringify({ + choices: [{ delta: { reasoning_content: reasoningText } }], + }) + + '\n' + ); + } + lines.push( + 'data: ' + + JSON.stringify({ choices: [{ delta: { content: 'Done.' } }] }) + + '\n', + 'data: ' + + JSON.stringify({ + choices: [{ delta: {}, finish_reason: 'stop' }], + }) + + '\n', + 'data: [DONE]\n' + ); + const sse = lines.join(''); + let readCount = 0; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => 'text/event-stream' }, + body: { + getReader: () => ({ + read: async () => { + readCount += 1; + if (readCount === 1) { + return { done: false, value: encoder.encode(sse) }; + } + return { done: true, value: undefined }; + }, + }), + }, + }); + const created = await customCreateAiRequest({ + userRequest: 'start', + mode: 'chat', + }); + return { + total: customGetAiRequestTokenTotal(created.id), + reasoningTokens: estimateTokens(reasoningText), + }; + }; + + setCustomEndpointConfig({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'deepseek-r1', + temperature: 0.7, + streaming: true, + }); + + const withReasoning = await runCreate(true); + const without = await runCreate(false); + + // Identical prompt and answer: the only difference is the chain of + // thought, which must be billed. + expect(withReasoning.total - without.total).toBe( + withReasoning.reasoningTokens + ); + }); + + it('still counts a plain answer with no reasoning', async () => { + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { + choices: [ + { message: { role: 'assistant', content: 'x'.repeat(400) } }, + ], + }, + }); + const created = await customCreateAiRequest({ + userRequest: 'start', + mode: 'chat', + }); + expect(customGetAiRequestTokenTotal(created.id)).toBeGreaterThan( + estimateTokens('x'.repeat(400)) + ); + }); + }); + + describe('message estimate counts whole tool calls', () => { + it('charges the tool name and call id, not only the arguments', () => { + // A tool call costs more than its arguments: the model also receives the + // name and id. Counting only arguments under-reported a 30-exchange + // transcript by ~80%, so the prompt read as inside the budget while it + // was over it. + const message = { + role: 'assistant', + content: null, + tool_calls: [ + { + id: 'call_abcdefghij', + type: 'function', + function: { + name: 'change_scene_properties_layers_effects_groups', + arguments: '{"a":1}', + }, + }, + ], + }; + const argumentsOnly = estimateTokens('{"a":1}'); + const estimate = estimateMessagesTokens([message]); + expect(estimate).toBeGreaterThan(argumentsOnly); + // The name alone is longer than the arguments here, so the difference is + // substantial rather than a rounding detail. + expect(estimate).toBe( + estimateTokens('call_abcdefghij') + + estimateTokens('change_scene_properties_layers_effects_groups') + + argumentsOnly + ); + }); + + it('charges the call id echoed back on a tool reply', () => { + const toolReply = { + role: 'tool', + tool_call_id: 'call_abcdefghij', + content: '{"ok":true}', + }; + expect(estimateMessagesTokens([toolReply])).toBe( + estimateTokens('{"ok":true}') + estimateTokens('call_abcdefghij') + ); + }); + + it('counts a partial tool call and tolerates a null entry', () => { + // $FlowFixMe deliberately malformed entries for the guards. A call with + // only an id still costs that id; a null entry costs nothing and must + // not throw. + expect( + estimateMessagesTokens([ + { role: 'assistant', content: null, tool_calls: [null] }, + ]) + ).toBe(0); + expect( + estimateMessagesTokens([ + { role: 'assistant', content: null, tool_calls: [{ id: 'c1' }] }, + ]) + ).toBe(estimateTokens('c1')); + }); + }); + + describe('output allowance in the context budget', () => { + const base = { + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + temperature: 0.7, + }; + + it('reserves half the window when no maxTokens is set', () => { + // qwen -> 32768 window; the conservative default still holds half back. + expect(getTokenBudget({ ...base, model: 'qwen2.5-coder' })).toBe(16384); + expect(getTokenBudget({ ...base, model: 'llama3.2' })).toBe(4096); + }); + + it('reserves exactly maxTokens when the user capped the reply', () => { + // A 512-token cap must not hold back 16384 tokens of a 32768 window. + expect( + getTokenBudget({ ...base, model: 'qwen2.5-coder', maxTokens: 512 }) + ).toBe(32768 - 512); + expect( + getTokenBudget({ ...base, model: 'llama3.2', maxTokens: 256 }) + ).toBe(8192 - 256); + }); + + it('never reserves more than half the window, however large maxTokens is', () => { + // A cap beyond the window must not drive the input budget to zero (or + // negative): the history would lose its system prompt entirely. + expect( + getTokenBudget({ ...base, model: 'llama3.2', maxTokens: 999999 }) + ).toBe(4096); + expect( + getTokenBudget({ ...base, model: 'qwen2.5-coder', maxTokens: 32768 }) + ).toBe(16384); + }); + + it('ignores a non-positive or non-numeric maxTokens', () => { + expect(getTokenBudget({ ...base, model: 'llama3.2', maxTokens: 0 })).toBe( + 4096 + ); + expect( + getTokenBudget({ ...base, model: 'llama3.2', maxTokens: -10 }) + ).toBe(4096); + // $FlowFixMe deliberately malformed config for the guard. + expect( + getTokenBudget({ ...base, model: 'llama3.2', maxTokens: 'lots' }) + ).toBe(4096); + }); + + it('keeps a project structure the default half-window reserve would compact', async () => { + // Size the structure to sit between the two budgets: it exceeds the + // default reserve (16384 - tools) but fits the widened one + // (32768 - 512 - tools). Pre-fix this always reported compaction. + const qwen = { ...base, model: 'qwen2.5-coder' }; + const widened = { ...qwen, maxTokens: 512 }; + const structure = JSON.stringify({ + scenes: Array.from({ length: 90 }, (_, i) => ({ + name: 'Scene' + i, + events: Array.from({ length: 8 }, (_, j) => ({ + type: 'Event' + j, + code: 'x'.repeat(70), + })), + })), + }); + const structureTokens = estimateTokens(structure); + expect(structureTokens).toBeGreaterThan( + getMessageBudget(qwen, GDEVELOP_OPENAI_TOOLS) + ); + expect(structureTokens).toBeLessThan( + getMessageBudget(widened, GDEVELOP_OPENAI_TOOLS) + ); + + setCustomEndpointConfig(widened); + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + + const created = await customCreateAiRequest({ + userRequest: 'start', + gameProjectJson: structure, + projectSpecificExtensionsSummaryJson: null, + mode: 'chat', + }); + + expect(axios.post.mock.calls[0][1].max_tokens).toBe(512); + // The structure survived intact: the freed context was actually used. + expect(customGetAiRequestSystemCompacted(created.id)).toBe(false); + }); + }); + + describe('history trimming decrements by real token cost', () => { + it('does not over-drop history when a CJK message is compacted', () => { + // Regression: compacting a message decremented the running estimate by + // removedChars/4. A CJK message costs a full token per character (4800 + // chars ~ 4800 tokens), so after one compaction the loop's running + // estimate still read 3734 while the real total was 528 — far above the + // budget it had actually reached, so it kept dropping older history that + // no longer needed to go. + const cjkBody = '创建精灵对象并设置生命值'.repeat(400); // 4800 chars + const messages = [ + { role: 'system', content: 'sys' }, + { role: 'assistant', content: cjkBody }, + { role: 'user', content: 'old-1' }, + { role: 'user', content: 'old-2' }, + { role: 'user', content: 'newest-a' }, + { role: 'user', content: 'newest-b' }, + ]; + // Sits between the true post-compaction total (~528) and the pre-fix + // inflated one (~3734): only the correct decrement stops here. + const budget = 2131; + + const trimmed = trimMessagesToBudget(messages, budget); + + // Both old messages survive, because compacting the CJK message alone + // brought the prompt under budget once its real cost was charged. + expect(trimmed.some(m => m.content === 'old-1')).toBe(true); + expect(trimmed.some(m => m.content === 'old-2')).toBe(true); + expect(estimateMessagesTokens(trimmed)).toBeLessThanOrEqual(budget); + }); + + it('keeps only a bounded head of a compacted message', () => { + // The newest exchange is protected, so the compacted message must sit + // earlier in the list for the head-slicing path to run at all. + const messages = [ + { role: 'system', content: 'sys' }, + { role: 'assistant', content: 'x'.repeat(20000) }, + { role: 'user', content: 'middle' }, + { role: 'user', content: 'newest' }, + ]; + const trimmed = trimMessagesToBudget(messages, 200); + const assistant = trimmed.find(m => m.role === 'assistant'); + expect(assistant.content).toContain('trimmed:'); + // The kept head is a token budget (~125 tokens), not a 500-char slice. + expect(estimateTokens(assistant.content)).toBeLessThan(300); + }); + }); + + describe('history trimming tolerates a hole in the message list', () => { + it('trims a list containing a null entry without throwing', () => { + // estimateMessagesTokens and the isProtected guard both already skip a + // null message; the keep/drop loop dereferenced it unguarded, so the + // same input crashed the trim the rest of the function tolerated. + const messages = [ + { role: 'system', content: 'sys' }, + null, + { role: 'assistant', content: 'x'.repeat(9000) }, + { role: 'user', content: 'middle' }, + { role: 'user', content: 'newest' }, + ]; + // $FlowFixMe deliberately malformed list for the guard. + const trimmed = trimMessagesToBudget(messages, 300); + expect(Array.isArray(trimmed)).toBe(true); + expect(estimateMessagesTokens(trimmed)).toBeLessThanOrEqual(300); + // The newest exchange is still there. + expect(trimmed.some(m => m && m.content === 'newest')).toBe(true); + }); + + it('does not crash when the null follows a dropped tool_calls assistant', () => { + // The guard at the top is `message && message.role === 'system'`, and the + // null-skip in estimateMessagesTokens, but the dropToolOutputs branch + // dereferenced message.role directly. Reaching it needs the drop to have + // started on an earlier assistant, which is why a plain hole mid-list + // did not reproduce it. + const messages = [ + { role: 'system', content: 'sys' }, + { + role: 'assistant', + content: 'calling a tool', + tool_calls: [{ id: 'c1', function: { name: 'f', arguments: '{}' } }], + }, + null, + { role: 'user', content: 'newest-a' }, + { role: 'user', content: 'newest-b' }, + ]; + // $FlowFixMe deliberately malformed list for the guard. + expect(() => trimMessagesToBudget(messages, 5)).not.toThrow(); + }); + }); + + describe('compaction honours the budget in non-Latin scripts', () => { + it('caps a CJK system prompt at its token budget, not 4x it', () => { + // Regression: estimateTokens counted CJK as one token per character (so + // ~4x the old flat rate) while the truncation still converted the token + // budget back to characters with a flat * 4 — a "capped" Chinese prompt + // came out four times over the budget it was capped for. + const budget = 400; + const cjk = '创建精灵对象并设置生命值'.repeat(200); // far over budget + const messages = [ + { role: 'system', content: 'Current Project Structure:\n' + cjk }, + { role: 'user', content: 'hi' }, + ]; + + const compacted = compactSystemMessageToBudget(messages, budget); + const system = compacted.find(m => m.role === 'system'); + expect(system).toBeTruthy(); + expect(estimateTokens(system.content)).toBeLessThanOrEqual( + budget - + 256 + + estimateTokens( + '\n[... project structure truncated to fit the model context window ...]\n' + ) + + 1 + ); + // The truncation note must actually have been applied. + expect(system.content).toContain('truncated to fit the model context'); + }); + + it('still compacts ASCII to its budget after the script-aware change', () => { + const budget = 400; + const ascii = 'x'.repeat(8000); + const messages = [ + { role: 'system', content: 'Current Project Structure:\n' + ascii }, + { role: 'user', content: 'hi' }, + ]; + const compacted = compactSystemMessageToBudget(messages, budget); + const system = compacted.find(m => m.role === 'system'); + expect(system.content).toContain('truncated to fit the model context'); + expect(estimateTokens(system.content)).toBeLessThanOrEqual(budget); + }); + + it('leaves a prompt that fits the budget untouched', () => { + const messages = [ + { role: 'system', content: 'short system prompt' }, + { role: 'user', content: 'hi' }, + ]; + expect(compactSystemMessageToBudget(messages, 400)).toBe(messages); + }); + }); + + describe('tool schema counts against the context budget', () => { + const llamaConfig = { + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + // llama family -> 8192 window -> 4096-token budget before tools. + model: 'llama3.2', + temperature: 0.7, + }; + + it('estimates the built-in toolset in the thousands of tokens', () => { + const toolsTokens = estimateToolsTokens(GDEVELOP_OPENAI_TOOLS); + expect(toolsTokens).toBeGreaterThan(4096); + }); + + it('subtracts the tools from the messages budget', () => { + // qwen -> 32768 window -> 16384 budget, comfortably above the schema, so + // the subtraction is observable without hitting the floor. + const qwenConfig = { ...llamaConfig, model: 'qwen2.5-coder' }; + expect(getTokenBudget(qwenConfig)).toBe(16384); + expect(getMessageBudget(qwenConfig, GDEVELOP_OPENAI_TOOLS)).toBe( + 16384 - estimateToolsTokens(GDEVELOP_OPENAI_TOOLS) + ); + }); + + it('keeps a usable floor when the tools cannot fit the window', () => { + // A llama window (4096 budget) is smaller than the tool schema. The + // budget must not collapse to zero: that would strip the system prompt + // and all history, which is worse than an oversized request the server + // reports normally. + expect(getMessageBudget(llamaConfig, GDEVELOP_OPENAI_TOOLS)).toBe(1024); + }); + + it('is unchanged when no tools are sent', () => { + expect(getMessageBudget(llamaConfig, [])).toBe( + getTokenBudget(llamaConfig) + ); + expect(getMessageBudget(llamaConfig)).toBe(getTokenBudget(llamaConfig)); + }); + + it('leaves room for the tools on a model that can hold them', async () => { + setCustomEndpointConfig({ ...llamaConfig, model: 'qwen2.5-coder' }); + // $FlowFixMe + axios.post.mockResolvedValueOnce({ + status: 200, + data: { choices: [{ message: { role: 'assistant', content: 'ok' } }] }, + }); + + await customCreateAiRequest({ userRequest: 'start', mode: 'chat' }); + + const body = axios.post.mock.calls[0][1]; + // Message estimate must respect the smaller budget AND the tools must + // still be sent — budgeting must not strip them. + expect(estimateMessagesTokens(body.messages)).toBeLessThanOrEqual( + getMessageBudget({ ...llamaConfig, model: 'qwen2.5-coder' }, body.tools) + ); + expect(Array.isArray(body.tools)).toBe(true); + expect(body.tools.length).toBeGreaterThan(0); + }); + }); + + describe('the stream ends at [DONE]', () => { + it('returns as soon as the server sends [DONE], without waiting for close', async () => { + // Many OpenAI-compatible servers and proxies keep the socket open after + // the terminator. Waiting for a close means a finished answer sits idle + // until the watchdog aborts it, so the loop must stop on [DONE]. + const encoder = new TextEncoder(); + const body = + 'data: ' + + JSON.stringify({ + choices: [{ delta: { content: 'finished' }, finish_reason: 'stop' }], + }) + + '\n' + + 'data: [DONE]\n'; + let readCount = 0; + // A read that never resolves models the held-open socket. + const neverResolvingRead = () => new Promise(() => {}); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => 'text/event-stream' }, + body: { + getReader: () => ({ + read: async () => { + readCount += 1; + if (readCount === 1) { + return { done: false, value: encoder.encode(body) }; + } + return neverResolvingRead(); + }, + }), + }, + }); + + // If the loop waited for close this promise would never settle. + const message = await sendChatCompletion({ + messages: [{ role: 'user', content: 'hi' }], + config: { + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'qwen2.5-coder', + temperature: 0.7, + streaming: true, + }, + }); + + expect(message.content).toBe('finished'); + // Exactly one chunk plus the terminator: no further read was needed. + expect(readCount).toBe(1); + // $FlowFixMe[method-unbinding] jest matcher on a typed axios instance. + expect(axios.post).not.toHaveBeenCalled(); + }); + }); + + describe('partial content while the first turn streams', () => { + // The chat's cold-start hint reads customGetAiRequestPartialContent to tell + // "no bytes yet" apart from "already streaming". Only addMessage used to + // publish it, so a first turn showed "waiting for the first token" while + // its text was visibly arriving. + // Two reads: the delta, then the terminator. Sampling at the top of the + // second read observes the registry as the turn left it after processing + // the delta, which is mid-turn — the `[DONE]` line then ends the loop. + const deltaBody = + 'data: ' + + JSON.stringify({ choices: [{ delta: { content: 'Hel' } }] }) + + '\n'; + const doneBody = 'data: [DONE]\n'; + + // Sampling hook that observes the registry from inside the turn. It runs + // as a microtask queued after the chunk is handed to the reader, because + // the `data: [DONE]` line now ends the read loop and there is no second + // `read()` to hang the observation off. + let samplePartialContent = () => null; + + const mockStreamResponse = () => { + const encoder = new TextEncoder(); + return { + ok: true, + status: 200, + body: { + getReader: () => ({ + read: (() => { + let readCount = 0; + return async () => { + readCount += 1; + if (readCount === 1) { + return { done: false, value: encoder.encode(deltaBody) }; + } + // Observing here catches the registry after the delta was + // processed and before the turn settles. + samplePartialContent(); + if (readCount === 2) { + return { done: false, value: encoder.encode(doneBody) }; + } + return { done: true, value: undefined }; + }; + })(), + }), + }, + }; + }; + + it('publishes partial content during a create turn and clears it after', async () => { + setCustomEndpointConfig({ + enabled: true, + baseUrl: 'http://localhost:11434/v1', + apiKey: '', + model: 'qwen2.5-coder', + temperature: 0.7, + streaming: true, + }); + + // Freeze the id inputs so the in-flight request id is predictable. + const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1700000000000); + const randomSpy = jest.spyOn(Math, 'random').mockReturnValue(0.5); + const expectedId = `local-ai-1700000000000-${(0.5) + .toString(36) + .substr(2, 7)}`; + + let seenDuringRequest = null; + // The reader returns the delta first and is called again for the `done` + // poll; sampling on that second read captures the registry as it stood + // after onStreamDelta ran for the delta chunk. + global.fetch = jest + .fn() + .mockImplementation(() => Promise.resolve(mockStreamResponse())); + + samplePartialContent = () => { + seenDuringRequest = customGetAiRequestPartialContent(expectedId); + }; + + const created = await customCreateAiRequest({ + userRequest: 'start', + mode: 'chat', + }); + + expect(created.id).toBe(expectedId); + // The registry must have carried the streamed bytes mid-turn... + expect(seenDuringRequest).toBe('Hel'); + // ...and must not leak into the next turn. + expect(customGetAiRequestPartialContent(expectedId)).toBe(''); + + nowSpy.mockRestore(); + randomSpy.mockRestore(); + }); + }); +}); diff --git a/packages/ai-studio/AiGeneration/Studio/EditApprovalLabel.js b/packages/ai-studio/AiGeneration/Studio/EditApprovalLabel.js new file mode 100644 index 0000000..e3abd05 --- /dev/null +++ b/packages/ai-studio/AiGeneration/Studio/EditApprovalLabel.js @@ -0,0 +1,34 @@ +// @flow +import { getAllSubAgentFunctionCalls } from '../AiRequestUtils'; +import { + type AiRequest, + type AiRequestMessageAssistantFunctionCall, +} from '../../Utils/GDevelopServices/Generation'; + +/** + * The `spawn_agent` call that launched a sub-agent, read back from its + * parent's output — or null when it cannot be resolved (a top-level request, + * a missing parent, pruned history, or a persisted null hole). + * + * The edit-approval prompt names the agent after this call (its short title, + * the same name shown in the chat); a null falls back to the tool labels of + * the batch itself. Lives here rather than inline in the dispatcher so a + * spec can pin the resolution: Utils.js pulls in three.js through the + * editor-function map and cannot be loaded by a test. + */ +export const getEditApprovalLaunchingCall = ({ + aiRequest, + aiRequests, +}: {| + aiRequest: AiRequest, + aiRequests: { [string]: AiRequest }, +|}): AiRequestMessageAssistantFunctionCall | null => { + if (!aiRequest.parentAiRequestId) return null; + const parentRequest = aiRequests[aiRequest.parentAiRequestId] || null; + if (!parentRequest) return null; + return ( + getAllSubAgentFunctionCalls({ aiRequest: parentRequest }).find( + functionCall => functionCall.subAgentAiRequestId === aiRequest.id + ) || null + ); +}; diff --git a/packages/ai-studio/AiGeneration/Studio/FinalizeSubAgents.js b/packages/ai-studio/AiGeneration/Studio/FinalizeSubAgents.js new file mode 100644 index 0000000..dce468b --- /dev/null +++ b/packages/ai-studio/AiGeneration/Studio/FinalizeSubAgents.js @@ -0,0 +1,262 @@ +// @flow + +import { type AiRequest } from '../../Utils/GDevelopServices/Generation'; +import { type EditorFunctionCallResult } from '../../EditorFunctions'; +import { + getAllSubAgentFunctionCalls, + getFunctionCallsToProcess, + getPendingSubAgentFunctionCalls, +} from '../AiRequestUtils'; +import { getStudioRole, type StudioRoleId } from './Roles'; +import { isSpawnAgentCall, parseSpawnAgentArgs } from './SpawnSubAgents'; +import { truncateAtCodePointBoundary } from './SafeTruncation'; + +/** + * The role a studio sub-agent was spawned with, read back from its parent's + * `spawn_agent` call. Null for a hosted sub-agent (`run_edit_agent` / + * `run_explorer_agent`) or when the launch call cannot be resolved. + */ +export const getSubAgentRoleId = ({ + aiRequest, + aiRequests, +}: {| + aiRequest: AiRequest, + aiRequests: { [string]: AiRequest }, +|}): StudioRoleId | null => { + if (!aiRequest.parentAiRequestId) return null; + const parentRequest = aiRequests[aiRequest.parentAiRequestId] || null; + if (!parentRequest) return null; + const launchingCall = getAllSubAgentFunctionCalls({ + aiRequest: parentRequest, + }).find(functionCall => functionCall.subAgentAiRequestId === aiRequest.id); + if (!launchingCall || !isSpawnAgentCall(launchingCall)) return null; + const parsedArgs = parseSpawnAgentArgs(launchingCall); + return parsedArgs ? parsedArgs.role : null; +}; + +/** How many model turns a sub-agent has taken: one assistant message each. */ +export const countAssistantTurns = (aiRequest: AiRequest): number => + (aiRequest.output || []).filter( + // A persisted output is not shape-validated; a null/scalar entry would + // throw here, in the turn-cap check that stops a runaway sub-agent. + message => + message && message.type === 'message' && message.role === 'assistant' + ).length; + +/** + * True when a studio sub-agent has used up its role's turn budget. + * + * The dispatcher stops processing its remaining calls at that point and the + * studio runtime finalizes it, so a runaway sub-agent cannot run forever. + */ +export const isSubAgentAtTurnCap = ({ + aiRequest, + aiRequests, +}: {| + aiRequest: AiRequest, + aiRequests: { [string]: AiRequest }, +|}): boolean => { + const roleId = getSubAgentRoleId({ aiRequest, aiRequests }); + if (!roleId) return false; + return countAssistantTurns(aiRequest) >= getStudioRole(roleId).maxTurns; +}; + +/** + * Whether the parent request already carries a `function_call_output` for a + * call: the child is on its way out (`removeSubAgentIfDone` retires it) and + * must not be finalized again. The studio runtime checks this on every pass, + * so like the turn-cap counter it tolerates a null hole in the persisted + * output instead of throwing out of the hook effect. + */ +export const parentHasOutputForCall = ( + parentRequest: AiRequest, + callId: string +): boolean => + (parentRequest.output || []).some( + message => + !!message && + message.type === 'function_call_output' && + message.call_id === callId + ); + +/** + * A sub-agent is finished when it has nothing left to do locally: it is not + * working, it has no unexecuted function calls, and it has no pending sub-agent + * of its own. + * + * A hosted sub-agent (`run_edit_agent`) is polled until the server retires it; + * this only decides when the *studio* may write its report back. + */ +export const isSubAgentFinished = ({ + subAgentRequest, + editorFunctionCallResults, + aiRequests, +}: {| + subAgentRequest: AiRequest, + editorFunctionCallResults: Array | null, + aiRequests?: { [string]: AiRequest } | null, +|}): boolean => { + // A sub-agent at its turn cap is finalized with its last message even if it + // still has calls left: the dispatcher has stopped issuing its turns. + if ( + aiRequests && + isSubAgentAtTurnCap({ aiRequest: subAgentRequest, aiRequests }) + ) { + return true; + } + // The loop guard's terminal state is 'error'; treat it as finished so the + // studio writes its (failed) report back instead of waiting forever. + if ( + subAgentRequest.status !== 'ready' && + subAgentRequest.status !== 'error' + ) { + return false; + } + if ( + getFunctionCallsToProcess({ + aiRequest: subAgentRequest, + editorFunctionCallResults, + }).length > 0 + ) { + return false; + } + if ( + getPendingSubAgentFunctionCalls({ aiRequest: subAgentRequest }).length > 0 + ) { + return false; + } + if ( + editorFunctionCallResults && + editorFunctionCallResults.some(result => result.status === 'working') + ) { + return false; + } + // A result that was executed but whose output has not landed in the child's + // transcript yet: finalizing now would drop the child's real wrap-up forever. + const writtenBackCallIds = new Set( + (subAgentRequest.output || []) + .filter(message => message && message.type === 'function_call_output') + .map(message => (message: any).call_id) + ); + if ( + editorFunctionCallResults && + editorFunctionCallResults.some( + result => !writtenBackCallIds.has(result.call_id) + ) + ) { + return false; + } + return true; +}; + +/** Reports are capped so one runaway sub-agent cannot flood the parent. */ +export const MAX_SUB_AGENT_REPORT_LENGTH = 4000; + +/** + * Cut a report to `maxLength` without splitting a surrogate pair. + * + * A plain `slice` can land between the halves of an astral character (an emoji + * or rare CJK in a sub-agent's report — realistic for a game studio naming + * scenes and objects), leaving a lone high surrogate that encodes to U+FFFD. + * The result is written into the parent transcript and sent to the model, so + * the replacement character would appear in place of the real text. + */ +export const truncateReport = (report: string, maxLength: number): string => { + if (report.length <= maxLength) return report; + return ( + truncateAtCodePointBoundary(report, maxLength) + '\n…(report truncated)' + ); +}; + +/** + * The text written into the parent's `function_call_output`: the sub-agent's + * last assistant message, prefixed with any failure or turn-limit context and + * suffixed with its failed calls, then truncated. The message's text is read + * from a `content` entry (`'output_text'` from the server, `'text'` from the + * local BYOK parser) with the message's own `text` field as fallback. + */ +export const buildSubAgentReport = ( + subAgentRequest: AiRequest, + aiRequests?: { [string]: AiRequest } | null +): string => { + const output = subAgentRequest.output || []; + let report = ''; + + if (subAgentRequest.error) { + report += `Failed: ${subAgentRequest.error.message}\n\n`; + } + if ( + aiRequests && + isSubAgentAtTurnCap({ aiRequest: subAgentRequest, aiRequests }) + ) { + report += 'Reached its turn limit.\n\n'; + } + + // The sub-agent's last assistant message is the core of the report. Its text + // lives in a `content` entry whose type is `'output_text'` for a message the + // server produced, and `'text'` for one the local BYOK path parsed; both are + // read, and the message's own `text` field is the fallback. + let core = '(no report)'; + for (let i = output.length - 1; i >= 0; i--) { + const message = output[i]; + if (!message || typeof message !== 'object') continue; + if (message.type !== 'message' || message.role !== 'assistant') continue; + // A sub-agent message from the server may lack its content array; the + // report falls back to the message's own `text` field below. + const content = message.content; + const textContent = Array.isArray(content) + ? content.find( + entry => + entry && (entry.type === 'output_text' || entry.type === 'text') + ) + : null; + const textFromContent = textContent ? (textContent: any).text : null; + if (typeof textFromContent === 'string' && textFromContent) { + core = textFromContent; + } else { + const messageText = (message: any).text; + core = + typeof messageText === 'string' && messageText + ? messageText + : '(no report)'; + } + break; + } + report += core; + + // Surface failed tool calls so the parent sees what went wrong. + const failedMessages = output + .filter( + message => + message && + message.type === 'function_call_output' && + (message: any).success === false + ) + .map(message => { + const messageOutput = (message: any).output; + return (messageOutput && messageOutput.message) || '(failed call)'; + }); + if (failedMessages.length > 0) { + report += `\n\nFailed calls:\n- ${failedMessages.join('\n- ')}`; + } + + if (report.length > MAX_SUB_AGENT_REPORT_LENGTH) { + return truncateReport(report, MAX_SUB_AGENT_REPORT_LENGTH); + } + return report; +}; + +/** + * The label shown for the sub-agent's report. Falls back to the generic name for + * a hosted sub-agent, whose role this fork does not know. + */ +export const getSubAgentReportLabel = ({ + aiRequest, + aiRequests, +}: {| + aiRequest: AiRequest, + aiRequests: { [string]: AiRequest }, +|}): string => { + const roleId = getSubAgentRoleId({ aiRequest, aiRequests }); + return roleId ? getStudioRole(roleId).displayName : 'Agent'; +}; diff --git a/packages/ai-studio/AiGeneration/Studio/LoopGuard.js b/packages/ai-studio/AiGeneration/Studio/LoopGuard.js new file mode 100644 index 0000000..38a972a --- /dev/null +++ b/packages/ai-studio/AiGeneration/Studio/LoopGuard.js @@ -0,0 +1,276 @@ +// @flow + +import jsSHA from '../../Utils/Sha256'; + +/** + * The local loop guard: a circuit breaker for a BYOK agent. + * + * 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 + * fire, `MAX_AI_REQUEST_RETRIES_IN_A_ROW` is a UI gate the API enforces + * (`AiRequestUtils.js`), and a model looping on *fresh* call ids is otherwise + * unbounded. + * + * Three trips, all from munder-difflin: + * - `repeatedToolLimit` consecutive identical tool calls + * (`'looping: N identical calls to '`); + * - `errorStormLimit` consecutive failed calls + * (`'error storm: N consecutive failures'`); + * - `maxTurnsWithoutUserInput` assistant turns, cleared only by `reset` + * (`'turn limit: N turns without user input'`) - the trip with no + * munder-difflin analogue that this editor needs. + * + * Escalation is munder-difflin's ladder (`breaker.ts` `tick`): one level per + * `evaluate`, capped at `constrained` (there is no `hardStop` here - the guard + * never kills a request, it steers and constrains). A non-tripping `evaluate` + * de-escalates one level. `action` is non-`'none'` only when the level escalated, + * so a durable steer is not re-sent every tick. + * + * Deliberately NOT ported: the cost and token-velocity trips (the BYOK path has + * no usage feed), the `hardStop` level, and the `PreCompact` exemption (there is + * no auto-compaction here). + */ + +export type LoopGuardLevel = 'healthy' | 'steering' | 'constrained' | 'stopped'; +export type LoopGuardAction = 'none' | 'steer' | 'constrain' | 'stop'; + +export type LoopGuardDecision = {| + level: LoopGuardLevel, + reason: string, + action: LoopGuardAction, + /** True when the level changed since the previous `evaluate` (either way). */ + changed: boolean, +|}; + +export const DEFAULT_LOOP_GUARD_CONFIG = { + enabled: true, + /** Trips on the Nth consecutive identical call; N-1 are tolerated. */ + repeatedToolLimit: 8, + /** Trips on the Nth consecutive failed call; N-1 are tolerated. */ + errorStormLimit: 5, + /** + * Trips on the Nth assistant turn without a user message; N-1 are tolerated. + * Generous on purpose: a legitimate long task (a `run_script` doing hundreds + * of calls, a gameplay test) is not a loop. + */ + maxTurnsWithoutUserInput: 60, + /** + * Trips on the Nth consecutive turn with no project-modifying tool call; N-1 + * are tolerated. A sub-agent that only inspects is stuck (this is the + * objective's "zero project mods over N turns" thrash signature). Never + * applied to the manager, whose job is to delegate without editing. + */ + maxTurnsWithoutProgress: 5, +}; + +export type LoopGuardConfig = { + enabled: boolean, + repeatedToolLimit: number, + errorStormLimit: number, + maxTurnsWithoutUserInput: number, + maxTurnsWithoutProgress: number, +}; + +const LEVELS: Array = [ + 'healthy', + 'steering', + 'constrained', + 'stopped', +]; + +const rank = (level: LoopGuardLevel): number => LEVELS.indexOf(level); + +const actionFor = (level: LoopGuardLevel): LoopGuardAction => { + if (level === 'steering') return 'steer'; + if (level === 'constrained') return 'constrain'; + if (level === 'stopped') return 'stop'; + return 'none'; +}; + +/** Recursively stringify with object keys sorted, so key order is not identity. */ +const stableStringify = (value: any): string => { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]`; + } + if (value && typeof value === 'object') { + const keys = Object.keys(value).sort(); + return `{${keys + .map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(',')}}`; + } + return JSON.stringify(value); +}; + +/** + * Parse `argsJson` and re-emit it canonically (keys sorted) so two argument + * objects that differ only in key order hash the same. A non-JSON string is + * hashed as-is. + */ +const canonicalizeArgs = (argsJson: string): string => { + try { + return stableStringify(JSON.parse(argsJson || 'null')); + } catch (error) { + return argsJson || ''; + } +}; + +/** + * The identity of a tool call: its name and a hash of its arguments, taken + * whole and in canonical (key-sorted) form. + * + * Hashing the whole arguments is the point. munder-difflin's #377 records that + * its old `input.slice(0, 200)` collided on Bash commands sharing a long + * identical preamble - nine *different* measurements in a row read as nine + * identical calls and constrained the agent. Different arguments must produce + * different keys, however long their common prefix; canonicalizing means two + * calls that differ only in argument key order are the *same* call. + */ +export const toolCallKey = (name: string, argsJson: string): string => { + const shaObj = new jsSHA('SHA-256', 'TEXT', { encoding: 'UTF8' }); + shaObj.update(canonicalizeArgs(argsJson)); + return `${name}:${shaObj.getHash('HEX')}`; +}; + +export type LoopGuard = {| + recordToolCall: (call: {| name: string, arguments: string |}) => void, + recordSuccess: () => void, + recordError: () => void, + recordTurn: () => void, + /** Record whether the last turn changed the project (progress). */ + recordProgress: (madeProgress: boolean) => void, + reset: () => void, + evaluate: () => LoopGuardDecision, +|}; + +export const createLoopGuard = ( + config?: Partial +): LoopGuard => { + const effectiveConfig: LoopGuardConfig = { + ...DEFAULT_LOOP_GUARD_CONFIG, + ...(config || {}), + }; + + let level: LoopGuardLevel = 'healthy'; + let reason = ''; + let repeatKey: string | null = null; + let repeatCount = 0; + let errorCount = 0; + let turnCount = 0; + let noProgressTurns = 0; + let actionEmittedForTrip = false; + + const evaluateTrips = (): {| tripping: boolean, reason: string |} => { + if (repeatCount >= effectiveConfig.repeatedToolLimit) { + return { + tripping: true, + reason: `looping: ${repeatCount} identical calls to ${ + repeatKey ? repeatKey.split(':')[0] : 'a tool' + }`, + }; + } + if (errorCount >= effectiveConfig.errorStormLimit) { + return { + tripping: true, + reason: `error storm: ${errorCount} consecutive failures`, + }; + } + if (noProgressTurns >= effectiveConfig.maxTurnsWithoutProgress) { + return { + tripping: true, + reason: `no progress: ${noProgressTurns} turns without changing the project`, + }; + } + if (turnCount >= effectiveConfig.maxTurnsWithoutUserInput) { + return { + tripping: true, + reason: `turn limit: ${turnCount} turns without user input`, + }; + } + return { tripping: false, reason: '' }; + }; + + return { + recordToolCall: call => { + const key = toolCallKey(call.name, call.arguments); + if (key === repeatKey) { + repeatCount++; + } else { + repeatKey = key; + repeatCount = 1; + } + }, + recordSuccess: () => { + // Only a real success clears the error storm: an attempt is not progress. + errorCount = 0; + }, + recordError: () => { + errorCount++; + }, + recordTurn: () => { + turnCount++; + }, + recordProgress: madeProgress => { + // Any project change resets the streak: a task that reads for a while, + // then writes, is not stuck. Only a stretch of pure inspection trips. + noProgressTurns = madeProgress ? 0 : noProgressTurns + 1; + }, + reset: () => { + // A new user input: the counters start over, but the ladder is NOT + // jumped back to healthy - it de-escalates one level per `evaluate`, so + // the caller is not handed a steer it already acted on, and a user who + // speaks during a constrained run sees it recover over the next ticks. + repeatKey = null; + repeatCount = 0; + errorCount = 0; + turnCount = 0; + noProgressTurns = 0; + actionEmittedForTrip = false; + }, + evaluate: () => { + if (!effectiveConfig.enabled) { + const wasChanged = level !== 'healthy'; + level = 'healthy'; + reason = ''; + return { + level, + reason, + action: 'none', + changed: wasChanged, + }; + } + + const trip = evaluateTrips(); + // The ceiling has no `hardStop` here: the guard steers and constrains, + // it never kills a request. + const ceiling: LoopGuardLevel = 'constrained'; + let target: LoopGuardLevel; + if (trip.tripping) { + target = LEVELS[Math.min(rank(level) + 1, rank(ceiling))]; + } else { + target = LEVELS[Math.max(rank(level) - 1, 0)]; + } + const changed = target !== level; + const escalated = rank(target) > rank(level); + level = target; + reason = trip.tripping ? trip.reason : changed ? 'recovering' : reason; + + // Emit on an escalation (a new level carries its own action) or on a + // fresh trip after a reset - but never re-send a durable action on every + // tick. Re-armed by `reset` and on recovery. + const shouldEmit = escalated || (trip.tripping && !actionEmittedForTrip); + if (shouldEmit) { + actionEmittedForTrip = true; + } else if (!trip.tripping) { + actionEmittedForTrip = false; + } + + return { + level, + reason, + action: shouldEmit ? actionFor(target) : 'none', + changed, + }; + }, + }; +}; diff --git a/packages/ai-studio/AiGeneration/Studio/NudgePolicy.js b/packages/ai-studio/AiGeneration/Studio/NudgePolicy.js new file mode 100644 index 0000000..8eb5bb4 --- /dev/null +++ b/packages/ai-studio/AiGeneration/Studio/NudgePolicy.js @@ -0,0 +1,95 @@ +// @flow + +import { type StudioPlanTask } from './PlanStore'; + +/** + * When the local studio's manager has stalled: it produced a plan, then ended + * its turn with an actionable task still undelegated (and no sub-agent running), + * so the multi-agent loop would sit idle forever under BYOK. + * + * This is the decision half of `useStudioNudge`, kept pure and dependency-light + * (`PlanStore`'s types only) so it can be unit-tested. The caller supplies the + * facts it already has: whether the request is the top-level manager, whether a + * turn is in flight, how many sub-agents are live and how many spawn calls are + * still unanswered. + * + * The nudge is deliberately conservative: + * - only the top-level manager (never a sub-agent); + * - never while a turn is in flight or work is pending; + * - never with a live or unanswered sub-agent; + * - never twice for the same plan state (the caller passes the signature it + * already nudged), so a stalled plan is nudged once, not every render; + * - a suspended/errored request is left alone (the user may have stopped it). + */ +export type StudioNudgeInput = {| + /** True for the top-level manager request (no parent, no studio role). */ + isTopLevel: boolean, + status: string, + hasWorkInProgress: boolean, + isSending: boolean, + liveSubAgentCount: number, + pendingSubAgentCallCount: number, + /** Non-null only when an actionable (pending/in_progress) task exists. */ + planSignature: string | null, + previouslyNudgedSignature: string | null, + /** + * True only for a request touched recently. Opening an old, abandoned chat + * must not resurrect its plan and spend tokens. + */ + isRecent: boolean, +|}; + +export const shouldNudgeStudioManager = (input: StudioNudgeInput): boolean => { + if (!input.isTopLevel) return false; + if (!input.isRecent) return false; + if (input.status === 'suspended' || input.status === 'error') return false; + if (input.isSending) return false; + if (input.hasWorkInProgress) return false; + if (input.liveSubAgentCount > 0) return false; + if (input.pendingSubAgentCallCount > 0) return false; + if (!input.planSignature) return false; + return input.planSignature !== input.previouslyNudgedSignature; +}; + +/** + * A stable signature of a plan's actionable state: the ids and statuses of the + * tasks that still need attention, in order. Null when there is no plan or + * nothing is actionable (every task is done or voided). + * + * `agentCallId` is included so a task that was delegated (but whose status has + * not flipped yet) is still distinguishable; a status-only signature would + * miss it only in the window before the spawn is stamped. + */ +export const getActionablePlanSignature = ( + tasks: Array | null | void +): string | null => { + if (!Array.isArray(tasks) || tasks.length === 0) return null; + const actionable = tasks.filter( + task => + task && + typeof task === 'object' && + (task.status === 'pending' || task.status === 'in_progress') + ); + if (actionable.length === 0) return null; + return actionable + .map(task => `${task.id}:${task.status}:${task.agentCallId || ''}`) + .join('|'); +}; + +/** + * The follow-up message the nudge sends to the stalled manager. Written as the + * user so it flows through the existing send path unchanged, and prefixed so + * the transcript makes clear it was the studio, not the person. + */ +export const buildStudioNudgeMessage = ( + nextReadyTask: StudioPlanTask | null +): string => { + const taskHint = nextReadyTask + ? ` Next ready task: ${nextReadyTask.id} (${nextReadyTask.title}).` + : ''; + return ( + '[Automatic reminder from the studio] Your plan still has a task that is ' + + `not delegated.${taskHint} Call spawn_agent for it now, or report plainly ` + + 'why it cannot be done. Do not answer with prose only.' + ); +}; diff --git a/packages/ai-studio/AiGeneration/Studio/PlanStore.js b/packages/ai-studio/AiGeneration/Studio/PlanStore.js new file mode 100644 index 0000000..c103560 --- /dev/null +++ b/packages/ai-studio/AiGeneration/Studio/PlanStore.js @@ -0,0 +1,399 @@ +// @flow + +/** + * The local plan store: the shape `create_or_update_plan` writes and the studio + * reads back. + * + * The plan shape itself is upstream's (`AiRequestPlan` / `AiRequestPlanTask` in + * `Utils/GDevelopServices/Generation.js`), and so is the reading half: + * `AiRequestUtils.getLatestActivePlan` pulls the last `function_call_output` + * whose JSON has `.plan.tasks`, and `AiRequestChat/OrchestratorPlan.js` renders + * it. This module only builds that exact shape and answers questions about it, + * so the hosted and BYOK paths return the same thing for the same tool. + */ + +export type StudioPlanTaskStatus = + | 'pending' + | 'in_progress' + | 'done' + | 'voided'; + +export type StudioPlanTask = {| + id: string, + title: string, + description: string, + status: StudioPlanTaskStatus, + dependsOn?: Array, + /** + * Set by the studio when the task is delegated, so the plan row can be linked + * to the sub-agent's function call (matches + * `AiRequestMessageAssistantFunctionCall.taskId`). + */ + agentCallId?: string, +|}; + +const PLAN_TASK_STATUSES: Array = [ + 'pending', + 'in_progress', + 'done', + 'voided', +]; + +export const isStudioPlanTaskStatus = (value: any): boolean => + typeof value === 'string' && PLAN_TASK_STATUSES.includes((value: any)); + +/** + * The output of `create_or_update_plan`: the exact nesting + * `getLatestActivePlan` parses (`output.plan.tasks`). + */ +export const buildPlanOutput = (tasks: Array): Object => ({ + success: true, + plan: { tasks }, +}); + +/** Read a task array back out of a plan tool's output. Null when malformed. */ +export const getPlanFromOutput = (output: Object): ?Array => { + if (!output || typeof output !== 'object') return null; + const plan: any = (output: any).plan; + if (!plan || typeof plan !== 'object') return null; + const tasks: any = plan.tasks; + if (!Array.isArray(tasks)) return null; + return tasks; +}; + +/** + * True when every id in `dependsOn` names a task that is done. An id matching no + * task is NOT satisfied: a dependency on missing work is not met. + */ +export const areTaskDependenciesSatisfied = ( + task: StudioPlanTask, + tasks: Array +): boolean => { + const doneIds = new Set( + tasks + // A null/scalar entry is reachable: plan tasks come from model-authored + // JSON and persisted output, neither shape-validated per element. + .filter( + otherTask => + otherTask && + typeof otherTask === 'object' && + otherTask.status === 'done' + ) + .map(otherTask => otherTask.id) + ); + return (task.dependsOn || []).every(dependencyId => + doneIds.has(dependencyId) + ); +}; + +/** The first pending task whose dependencies are satisfied, or null. */ +export const getNextReadyTask = ( + tasks: Array +): ?StudioPlanTask => + tasks.find( + task => + task && + typeof task === 'object' && + task.status === 'pending' && + areTaskDependenciesSatisfied(task, tasks) + ) || null; + +/** + * Fold `incoming` tasks over `existing`, matching by `id`. + * + * The result is `incoming` — its order and its membership, so a task dropped + * from the list is gone. What survives are the fields on the matching existing + * task that `incoming` does not mention. A field the caller DOES send wins, + * including an explicit null: that is how a field is cleared. A task without a + * string id passes through untouched. + * + * Ported from munder-difflin's `mergeTaskLedger` (`src/shared/taskLedger.ts`), + * which exists because a writer holding a partial model of a card silently + * deleted every field it did not know about. + */ +export const mergePlanTasks = ( + existing: Array, + incoming: Array +): Array => { + const existingList = Array.isArray(existing) ? existing : []; + const incomingList = Array.isArray(incoming) ? incoming : []; + const byId = new Map(); + for (const task of existingList) { + if (task && typeof task.id === 'string' && task.id && !byId.has(task.id)) { + byId.set(task.id, task); + } + } + return incomingList.map((task: any) => { + if (!task || typeof task.id !== 'string' || !task.id) return task; + const prior = byId.get(task.id); + return prior ? { ...prior, ...task } : task; + }); +}; + +/** + * Fold the plan inside a finished tool result's `output` over the plan the + * request already has. `output` is the object `EditorFunctionCallRunner` + * builds (`{ success, meta, ...output }`), so it is read as an object - never + * parsed. A result without a plan, or a non-success one, passes through + * untouched. + */ +export const mergePlanResultOutput = ( + result: Object, + existingTasks: Array | null +): Object => { + if (result.status !== 'finished' || !result.success) return result; + const output: any = result.output; + if (!output || !output.plan || !Array.isArray(output.plan.tasks)) { + return result; + } + if (!existingTasks) return result; + return { + ...result, + output: { + ...output, + plan: { tasks: mergePlanTasks(existingTasks, output.plan.tasks) }, + }, + }; +}; + +/** + * Apply `patch` to one task, leaving every other task — and every other field of + * the patched task — untouched. + * + * This is what a status flip must use. Rebuilding the task array from a model + * strips whatever fields the model does not know about (`agentCallId`, + * `description`, a future field), which is exactly what + * `patchTaskInLedger` exists to prevent in munder-difflin + * (`src/shared/taskLedger.ts`). + */ +export const patchPlanTask = ( + tasks: Array, + taskId: string, + patch: Partial +): Array => { + const list = Array.isArray(tasks) ? tasks : []; + return list.map(task => + task && task.id === taskId ? { ...task, ...patch } : task + ); +}; + +/** + * Mark the task a delegation named as delegated: link it to the sub-agent's + * call id and lift a still-pending task to in_progress. + * + * The status flip is the point. A plan task that stays `pending` does not + * render its linked function calls (`OrchestratorPlan.TaskRow`), so stamping + * `taskId` on the call without this would hide the spawn instead of showing it + * under its task. A task already done/voided keeps its status - only its link + * is set - so a late delegation cannot un-finish work. + */ +export const patchPlanTaskAsDelegated = ( + tasks: Array, + taskId: string, + callId: string +): Array => { + const list = Array.isArray(tasks) ? tasks : []; + const target = list.find(task => task && task.id === taskId); + if (!target) return list; + const status = target.status === 'pending' ? 'in_progress' : target.status; + return patchPlanTask(list, taskId, { status, agentCallId: callId }); +}; + +/** + * A copy of a request `output` with its latest plan message's tasks replaced, + * or null when there is no plan message to rewrite. + * + * Shared by the finalize writer (`UseStudioRuntime`) and the delegation writer + * (`Utils`), so both target the same message: the last `function_call_output` + * whose JSON carries `plan.tasks` - exactly what `getLatestActivePlan` reads. + */ +export const replacePlanMessage = ( + output: Array, + tasks: Array +): Array | null => { + const list = Array.isArray(output) ? output : []; + const planOutput = JSON.stringify(buildPlanOutput(tasks)); + for (let i = list.length - 1; i >= 0; i--) { + const message = list[i]; + if ( + !message || + typeof message !== 'object' || + message.type !== 'function_call_output' || + !message.output + ) { + continue; + } + try { + const parsed = JSON.parse(message.output); + if (parsed && parsed.plan && Array.isArray(parsed.plan.tasks)) { + const updated = [...list]; + updated[i] = { ...message, output: planOutput }; + return updated; + } + } catch (ignored) { + // A non-JSON output is skipped, like `getLatestActivePlan` does. + } + } + return null; +}; + +export type StudioPlanTaskValidation = + | {| success: true, tasks: Array |} + | {| success: false, message: string |}; + +const describeValue = (value: any): string => { + if (typeof value === 'string') return JSON.stringify(value); + if (value === null) return 'null'; + if (Array.isArray(value)) return 'an array'; + return typeof value; +}; + +/** + * Validate the `tasks` argument of `create_or_update_plan` as a local model + * sent it, naming the first offending index. A model mistake becomes a normal + * failed tool call, never a thrown error inside the editor. + */ +// Bounds on a plan the model sends, so a runaway tool call cannot flood the +// store (W12). +const MAX_PLAN_TASKS = 32; +const MAX_PLAN_TASK_ID_LENGTH = 64; +const MAX_PLAN_TASK_TITLE_LENGTH = 200; +const MAX_PLAN_TASK_DESCRIPTION_LENGTH = 1000; + +const ALLOWED_TASK_FIELDS = [ + 'id', + 'title', + 'description', + 'status', + 'dependsOn', + 'agentCallId', +]; + +export const validatePlanTasks = (rawTasks: any): StudioPlanTaskValidation => { + if (!Array.isArray(rawTasks)) { + return { + success: false, + message: 'create_or_update_plan requires a tasks array.', + }; + } + if (rawTasks.length > MAX_PLAN_TASKS) { + return { + success: false, + message: `create_or_update_plan accepts at most ${MAX_PLAN_TASKS} tasks, got ${ + rawTasks.length + }.`, + }; + } + + const tasks: Array = []; + const seenIds = new Set(); + for (let index = 0; index < rawTasks.length; index++) { + const rawTask = rawTasks[index]; + const invalid = (detail: string): StudioPlanTaskValidation => ({ + success: false, + message: `Invalid plan task at index ${index}: ${detail}`, + }); + + if (!rawTask || typeof rawTask !== 'object' || Array.isArray(rawTask)) { + return invalid(`expected an object, got ${describeValue(rawTask)}.`); + } + // Fail closed (I11): a field this validator does not model would be + // silently dropped by a rebuild, so reject the whole task instead. + for (const key of Object.keys(rawTask)) { + if (!ALLOWED_TASK_FIELDS.includes(key)) { + return invalid(`unexpected field ${JSON.stringify(key)}.`); + } + } + if ('agentCallId' in rawTask && typeof rawTask.agentCallId !== 'string') { + return invalid( + `\`agentCallId\` must be a string when present, got ${describeValue( + rawTask.agentCallId + )}.` + ); + } + if (typeof rawTask.id !== 'string' || !rawTask.id.trim()) { + return invalid( + `\`id\` must be a non-empty string, got ${describeValue(rawTask.id)}.` + ); + } + // Validate and store the trimmed id (I12); every later match is on it. + const id = rawTask.id.trim(); + if (id.length > MAX_PLAN_TASK_ID_LENGTH) { + return invalid( + `\`id\` must be at most ${MAX_PLAN_TASK_ID_LENGTH} characters.` + ); + } + if (seenIds.has(id)) { + return invalid(`duplicate \`id\` ${JSON.stringify(id)}.`); + } + seenIds.add(id); + if (typeof rawTask.title !== 'string' || !rawTask.title.trim()) { + return invalid( + `\`title\` must be a non-empty string, got ${describeValue( + rawTask.title + )}.` + ); + } + if (rawTask.title.trim().length > MAX_PLAN_TASK_TITLE_LENGTH) { + return invalid( + `\`title\` must be at most ${MAX_PLAN_TASK_TITLE_LENGTH} characters.` + ); + } + if ( + typeof rawTask.description !== 'string' || + !rawTask.description.trim() + ) { + return invalid( + `\`description\` must be a non-empty string, got ${describeValue( + rawTask.description + )}.` + ); + } + if (rawTask.description.trim().length > MAX_PLAN_TASK_DESCRIPTION_LENGTH) { + return invalid( + `\`description\` must be at most ${MAX_PLAN_TASK_DESCRIPTION_LENGTH} characters.` + ); + } + if (!isStudioPlanTaskStatus(rawTask.status)) { + return invalid( + `\`status\` must be one of ${PLAN_TASK_STATUSES.join( + ', ' + )}, got ${describeValue(rawTask.status)}.` + ); + } + const rawDependsOn: any = rawTask.dependsOn; + let dependsOn: Array; + if (rawDependsOn == null) { + dependsOn = []; + } else if ( + Array.isArray(rawDependsOn) && + rawDependsOn.every((id: any) => typeof id === 'string') + ) { + dependsOn = rawDependsOn.map((dependencyId: any) => dependencyId.trim()); + } else { + return invalid( + `\`dependsOn\` must be an array of strings, got ${describeValue( + rawDependsOn + )}.` + ); + } + + const task: StudioPlanTask = { + id, + title: rawTask.title, + description: rawTask.description, + status: (rawTask.status: StudioPlanTaskStatus), + }; + // `dependsOn` is set only when the caller sent it (W11): a re-plan that + // omits it must not clear dependencies another writer set. + if (rawDependsOn != null) { + task.dependsOn = dependsOn; + } + if (typeof rawTask.agentCallId === 'string') { + task.agentCallId = rawTask.agentCallId; + } + tasks.push(task); + } + + return { success: true, tasks }; +}; diff --git a/packages/ai-studio/AiGeneration/Studio/RequestWriteGate.js b/packages/ai-studio/AiGeneration/Studio/RequestWriteGate.js new file mode 100644 index 0000000..5abb767 --- /dev/null +++ b/packages/ai-studio/AiGeneration/Studio/RequestWriteGate.js @@ -0,0 +1,176 @@ +// @flow + +/** + * One gate owns every write to an `AiRequest`. + * + * Ported from munder-difflin's "one gate, one writer" (`src/renderer/src/ + * components/terminalAutomation.ts` + `docs/message-queue.md` §1). There, a + * second writer with its own idea of when the terminal was free landed its text + * on a half-written line; here, the studio runtime, the shared dispatcher and the + * user's own send all write to the same request, and a write that lands in the + * middle of a function-call batch can be silently replaced by the next one + * (`updateAiRequest(id, () => aiRequest)` discards previous state). + * + * The gate does two things: + * - `getRequestWriteBlock` says, with a named reason, why a write must wait; + * - `createRequestWriteQueue` serializes the writes per request, keeping at most + * one pending write (latest wins) and retrying a blocked one rather than + * dropping it. + * + * A `write` must never `await` an `enqueue` for the same `aiRequestId`: the drain + * loop cannot interleave and that promise would never settle. + */ + +export type RequestWriteBlock = + // The request is suspended: the editor has cleared its function-call results + // and a write now would re-populate them (`AiRequestContext:1438`). + | 'suspended' + // A user message is in flight for this request. + | 'sending' + // A function call of this request is being processed by the editor. + | 'call-in-flight' + // Nothing blocks a write. + | null; + +export const getRequestWriteBlock = ({ + aiRequestId, + request, + isSending, + inFlightCallIds, +}: {| + aiRequestId: string, + request: ?{ +status?: string }, + isSending: boolean, + inFlightCallIds: Set, +|}): RequestWriteBlock => { + if (request && request.status === 'suspended') return 'suspended'; + if (isSending) return 'sending'; + const prefix = `${aiRequestId}:`; + for (const callId of inFlightCallIds) { + if (callId.startsWith(prefix)) return 'call-in-flight'; + } + return null; +}; + +type PendingWrite = {| + write: () => Promise, + isBlocked: () => boolean, + resolve: () => void, + reject: (error: Error) => void, +|}; + +type QueueEntry = { + isRunning: boolean, + /** At most one pending write per request: a newer one replaces it. */ + pending: PendingWrite | null, +}; + +export type RequestWriteQueue = {| + enqueue: ( + aiRequestId: string, + write: () => Promise, + isBlocked: () => boolean + ) => Promise, + isIdle: (aiRequestId: string) => boolean, +|}; + +/** + * A per-request FIFO holding at most one pending write. + * + * A newer write for the same request replaces the older pending one (latest + * wins, matching munder-difflin's `MAX_PENDING_STEERS` drop-from-the-front rule + * in `src/main/control.ts`). A write whose `isBlocked()` is true is retried on + * the next `enqueue` for that request rather than dropped: the caller is not + * asked to poll, and the last enqueue always resolves when the queue drains. + */ +export const createRequestWriteQueue = (): RequestWriteQueue => { + const entries: Map = new Map(); + + const getEntry = (aiRequestId: string): QueueEntry => { + let entry = entries.get(aiRequestId); + if (!entry) { + entry = { isRunning: false, pending: null }; + entries.set(aiRequestId, entry); + } + return entry; + }; + + const runEntry = async (aiRequestId: string): Promise => { + const entry = getEntry(aiRequestId); + if (entry.isRunning) return; + + entry.isRunning = true; + try { + // Keep draining until there is nothing left to run: a write enqueued + // while this loop awaited has to be picked up by the same loop, not left + // to a second `runEntry` that would see `isRunning` and return. + while (entry.pending) { + const pending = entry.pending; + if (pending.isBlocked()) { + // Do not drop it, and do not wait for another enqueue (none may ever + // come): schedule a self-recheck. The promise stays pending until the + // write actually runs. + setTimeout(() => { + if (!entry.isRunning && entry.pending) { + runEntry(aiRequestId).catch(error => { + console.error('[studio] request write queue failure:', error); + }); + } + }, 50); + break; + } + entry.pending = null; + try { + await pending.write(); + pending.resolve(); + } catch (error) { + pending.reject(error); + } + } + } finally { + entry.isRunning = false; + if (!entry.pending) { + entries.delete(aiRequestId); + } + } + }; + + return { + enqueue: ( + aiRequestId: string, + write: () => Promise, + isBlocked: () => boolean + ): Promise => { + const entry = getEntry(aiRequestId); + // Latest wins: replace whatever was waiting, and settle it as superseded + // (rejected, not resolved - a replaced write never ran and must not report + // success). + if (entry.pending) { + const superseded = entry.pending; + entry.pending = null; + superseded.reject( + Object.assign( + new Error('Superseded by a newer write for this request.'), + { + code: 'superseded', + } + ) + ); + } + return new Promise((resolve, reject) => { + entry.pending = { write, isBlocked, resolve, reject }; + if (!entry.isRunning) { + runEntry(aiRequestId).catch(error => { + // `runEntry` handles its own per-write errors; this is a safety net + // so a bug there cannot produce an unhandled rejection. + console.error('[studio] request write queue failure:', error); + }); + } + }); + }, + isIdle: (aiRequestId: string): boolean => { + const entry = entries.get(aiRequestId); + return !entry || (!entry.isRunning && !entry.pending); + }, + }; +}; diff --git a/packages/ai-studio/AiGeneration/Studio/RoleToolPolicy.js b/packages/ai-studio/AiGeneration/Studio/RoleToolPolicy.js new file mode 100644 index 0000000..f6cc093 --- /dev/null +++ b/packages/ai-studio/AiGeneration/Studio/RoleToolPolicy.js @@ -0,0 +1,53 @@ +// @flow + +import { getStudioRole, isStudioRoleId } from './Roles'; + +/** + * The tool policy of the studio role stored on a sub-agent's request. + * + * `studioRoleId` is read from a persisted request and is not validated on load, + * so this resolves it defensively: + * + * - a declared role yields its own allowed-tool subset and display name; + * - an unrecognized id (the role was renamed or removed, or the cache is + * corrupt) yields an EMPTY allowed set, so every call is denied. That is the + * safe direction when the restriction cannot be determined — a read-only + * sub-agent must not regain mutations because its role disappeared — and it + * avoids passing the unknown id to `getStudioRole`, which throws and would + * stall the whole batch before a single call is dispatched; + * - `null`/absent means a top-level request with no role: `roleResolved` is + * false and `allowedToolNames` is empty; the caller only applies the filter + * when a role id is present. + * + * In its own module (importing only the leaf `Roles`) so it can be unit-tested: + * `AiGeneration/Utils.js` pulls in `EditorFunctions/index.js`, whose three.js + * dependency does not load under the test transform. + */ +export const getRoleToolPolicy = ( + studioRoleId: string | null +): {| + allowedToolNames: Array, + displayName: string | null, + roleResolved: boolean, +|} => { + if (studioRoleId && isStudioRoleId(studioRoleId)) { + const role = getStudioRole((studioRoleId: any)); + return { + allowedToolNames: role.allowedToolNames, + displayName: role.displayName, + roleResolved: true, + }; + } + return { allowedToolNames: [], displayName: null, roleResolved: false }; +}; + +/** + * Whether a request's studio role is read-only. An unresolvable role id is + * treated as read-only: the restrictive direction (no edit-approval bypass, no + * full script surface). + */ +export const isRoleReadOnly = (studioRoleId: string | null): boolean => { + if (!studioRoleId) return false; + if (!isStudioRoleId(studioRoleId)) return true; + return getStudioRole((studioRoleId: any)).readOnly; +}; diff --git a/packages/ai-studio/AiGeneration/Studio/Roles.js b/packages/ai-studio/AiGeneration/Studio/Roles.js new file mode 100644 index 0000000..5c6ed97 --- /dev/null +++ b/packages/ai-studio/AiGeneration/Studio/Roles.js @@ -0,0 +1,270 @@ +// @flow + +/** + * The role registry of the local (BYOK) AI game studio. + * + * Upstream's hosted backend runs a multi-agent company server-side: it decides + * which sub-agents exist and what each may do. Under BYOK there is no server, so + * the fork has to declare that here: for every role, the prompt it runs with, the + * exact tools it may call, whether it may change the project, and how many + * assistant turns it gets before it is finalized. + * + * Roles are a fork concept. Nothing in the hosted path reads this file: the + * server's own `run_edit_agent`/`run_explorer_agent` split is unrelated and keeps + * working untouched (see `EditorFunctions/index.js`). + */ + +export type StudioRoleId = 'manager' | 'designer' | 'developer' | 'tester'; + +export type StudioRole = {| + id: StudioRoleId, + /** Shown in the chat for the sub-agent. Becomes `short_title` of the spawn call. */ + displayName: string, + /** Prepended to the shared system prompt for this role's requests. */ + systemPrompt: string, + /** + * Exact tool names exposed to this role. Any name not listed here is never + * sent to the model (see `getToolsForRole`), so a role cannot be talked into + * calling a tool it was not granted. (`run_script` excepted: it reaches any + * registry function not in `NonScriptableFunctionNames`.) + */ + allowedToolNames: Array, + /** + * True when the role's tools may not mutate the project. Drives the read-only + * `run_script` restriction and skips the edit approval prompt. + */ + readOnly: boolean, + /** + * Hard cap on assistant turns for one sub-agent request. On reaching it, the + * agent is finalized with its last message (see `FinalizeSubAgents`). + */ + maxTurns: number, +|}; + +/** The tools that only read the project, shared by the roles that inspect. */ +export const READ_ONLY_TOOL_NAMES: Array = [ + 'read_game_project_json', + 'read_events_source', + 'describe_instances', + 'inspect_variables', + 'inspect_object_properties_effects', + 'inspect_scene_properties_layers_effects', + 'inspect_project_properties_resources', + 'inspect_behavior_properties', + 'inspect_extension', + 'search_object_asset_store', + 'search_resource_store', + 'search_docs', + 'read_full_docs', +]; + +/** Every tool that changes the project, in schema order. */ +export const MUTATING_TOOL_NAMES: Array = [ + 'run_script', + 'create_scene', + 'create_or_replace_object', + 'change_object_properties_effects', + 'add_behavior', + 'change_behavior_property', + 'put_2d_instances', + 'put_3d_instances', + // `generate_events` is a registry alias of this one, not a separate tool the + // schema exposes (see EditorFunctions/index.js). + 'add_scene_events', + 'change_scene_properties_layers_effects_groups', + 'change_project_properties_resources', + 'add_or_edit_variable', + 'create_extension', + 'change_extension_properties', + 'create_custom_object', + 'change_custom_object', + 'create_custom_behavior', + 'change_custom_behavior', + 'create_custom_function', + 'change_custom_function', + 'run_gameplay_test', + 'change_gameplay_tests', + 'initialize_project', +]; + +export const STUDIO_ROLES: { [StudioRoleId]: StudioRole } = { + manager: { + id: 'manager', + displayName: 'Studio lead', + readOnly: false, + maxTurns: 60, + allowedToolNames: [ + 'create_or_update_plan', + 'spawn_agent', + 'read_game_project_json', + 'describe_instances', + 'read_events_source', + 'inspect_variables', + 'inspect_scene_properties_layers_effects', + 'inspect_object_properties_effects', + 'inspect_extension', + 'inspect_project_properties_resources', + 'run_gameplay_test', + 'search_docs', + 'read_full_docs', + ], + systemPrompt: `You are the studio lead of a small game studio building a game in GDevelop. + +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: +1. Decompose the goal into a short list of concrete tasks with create_or_update_plan. Give each task an id, a title, a description of what "done" means, and any dependsOn. +2. Delegate each ready task with spawn_agent. Pick the role that matches the work: designer for the design document, developer for anything that changes the project, tester for verifying a feature. Give each sub-agent a complete, self-contained instruction - it cannot see this conversation. Include the facts already established (verified object and behavior type strings, resource and API constraints discovered, approaches that failed) in the instruction or context, so the agent acts on them instead of re-deriving them. +3. Pass the plan task's id as related_task_id, so the task is marked done when the agent reports back. +4. Never end your turn with prose while a ready task is still undelegated: your very next action after creating or updating the plan must be a spawn_agent call for each ready task. Saying that you will delegate is not delegating. +5. Wait for a sub-agent's report before treating its task as done. Do not spawn a second agent for the same task. +6. If a report says the work failed or is incomplete, spawn a follow-up agent with the specifics, or report the problem to the user. + +Finish with a short summary of what was built, what was verified, and what is left. If a task genuinely cannot be done with the tools available, say so plainly instead of delegating it again.`, + }, + + designer: { + id: 'designer', + displayName: 'Designer', + readOnly: false, + maxTurns: 30, + // The only mutating tool: a design document has to survive into the next + // role, and project variables are the only artifact a later agent can read. + allowedToolNames: [...READ_ONLY_TOOL_NAMES, 'add_or_edit_variable'], + systemPrompt: `You are the designer of a small game studio. You produce the game design document for the studio lead. + +You write the design as GDevelop project variables: one variable per section, named with the GDD_ prefix (for example GDD_Overview, GDD_Economy, GDD_Progression, GDD_Controls), with a string value holding the section's text. Project variables are the only artifact the next role can read - never write your design only as a chat message. + +You never create scenes, objects or events: another role does that. Your tools that change anything are limited to add_or_edit_variable. + +Read the project first with read_game_project_json and describe_instances, so the design fits what already exists. Be specific and numeric: name the resources, the costs, the rates and the win condition, so the developer does not have to invent them. Report which GDD_ variables you wrote.`, + }, + + developer: { + id: 'developer', + displayName: 'Developer', + readOnly: false, + maxTurns: 80, + allowedToolNames: [ + ...MUTATING_TOOL_NAMES, + 'read_game_project_json', + 'read_events_source', + 'describe_instances', + 'inspect_variables', + 'inspect_object_properties_effects', + 'inspect_behavior_properties', + 'inspect_scene_properties_layers_effects', + 'inspect_extension', + 'inspect_project_properties_resources', + 'search_object_asset_store', + 'search_resource_store', + 'search_docs', + 'read_full_docs', + ], + systemPrompt: `You are the developer of a small game studio. You implement exactly the task you were given, and nothing else. + +Read the GDD_ project variables first: they hold the design you are implementing. If the task you were given contradicts them, follow the task and say so in your report. + +Prefer run_script when a change needs many calls (placing a grid of instances, setting up many objects): one script doing fifty calls beats fifty tool calls. Await every call inside the script, and keep the script under a few hundred calls. Inside a script the editor functions are async functions in scope - never probe for project access via 'gd', globals, or webpack internals. + +Image textures can only enter the project inside asset-store objects (find them with search_object_asset_store, then create_or_replace_object imports the object with its assets); audio and font resources auto-install from the free library, images do not, and no tool uploads an image file. If no suitable texture exists, prefer objects that need no texture (PanelSprite, Text) instead of searching for an upload path. + +Inspect before you write: read the project, describe the instances and inspect the objects you are about to change, so you edit what actually exists. + +When you are done, report what you changed, naming the scenes, objects, variables and events you touched, and state anything you could not do. A partial implementation reported honestly is worth more than a silent failure.`, + }, + + tester: { + id: 'tester', + displayName: 'QA tester', + readOnly: true, + maxTurns: 30, + // Nothing here changes the project: the tester writes a test, runs it, and + // reports. `change_gameplay_tests` is the one exception - it edits a test + // definition, never the game. + allowedToolNames: [ + 'run_gameplay_test', + 'change_gameplay_tests', + 'read_game_project_json', + 'read_events_source', + 'describe_instances', + 'inspect_variables', + 'inspect_object_properties_effects', + 'inspect_scene_properties_layers_effects', + 'inspect_behavior_properties', + ], + systemPrompt: `You are the QA tester of a small game studio. You verify the feature you were asked about, and you never change game content. + +Write a gameplay test with run_gameplay_test, using harness assertions that actually exercise the named feature - assert on the state the feature is supposed to produce, not on it merely existing. Read the project first so your test names the real objects, scenes and variables. + +Run the test, then report PASS or FAIL and quote the assertions and any errors verbatim. If it fails, say which assertion failed and what the game did instead. If the feature is not implemented at all, say that plainly: a missing feature is a finding, not a test to make pass.`, + }, +}; + +/** The roles a `spawn_agent` call may name. A sub-agent never spawns sub-agents. */ +export const SPAWNABLE_ROLE_IDS: Array = [ + 'designer', + 'developer', + 'tester', +]; + +export const isStudioRoleId = (value: any): boolean => + typeof value === 'string' && + Object.prototype.hasOwnProperty.call(STUDIO_ROLES, (value: any)); + +export const isSpawnableRoleId = (value: any): boolean => + isStudioRoleId(value) && SPAWNABLE_ROLE_IDS.includes((value: StudioRoleId)); + +export const getStudioRole = (roleId: StudioRoleId): StudioRole => { + if (!Object.prototype.hasOwnProperty.call(STUDIO_ROLES, (roleId: any))) { + throw new Error(`Unknown studio role: ${String(roleId)}.`); + } + return STUDIO_ROLES[roleId]; +}; + +/** + * The subset of `allTools` a role may call, in `allTools` order. + * + * A role naming a tool that is not in `allTools` is not an error: the tool is + * skipped. That keeps a role declaration usable against an older tool schema + * (and lets `Roles.spec.js` state the invariant without importing a second + * source of truth). + */ +export const getToolsForRole = ( + roleId: StudioRoleId, + allTools: Array +): Array => { + const allowedNames = new Set(getStudioRole(roleId).allowedToolNames); + return allTools.filter( + tool => !!tool && !!tool.function && allowedNames.has(tool.function.name) + ); +}; + +/** + * The studio role that shapes a request's system prompt and tool subset. + * + * A sub-agent carries its role id. A top-level request carries none - but an + * `orchestrator`-mode chat is the studio's manager, so it must run with the + * manager's prompt and tool subset, not with every tool. Without this the + * "manager" ignored its own instructions and edited the project directly + * (feedback-loop Run 5). + * + * `invalid` distinguishes a present-but-unknown role id (a stale/corrupted + * persisted sub-agent) from no role: an invalid id must FAIL CLOSED to the + * read-only tool subset, while no role in a non-orchestrator chat keeps the + * full toolset. + */ +export const resolveStudioRoleId = ( + studioRoleId: string | null | void, + mode: string | null | void +): {| roleId: StudioRoleId | null, invalid: boolean |} => { + if (studioRoleId) { + return isStudioRoleId(studioRoleId) + ? { roleId: (studioRoleId: any), invalid: false } + : { roleId: null, invalid: true }; + } + return { + roleId: mode === 'orchestrator' ? 'manager' : null, + invalid: false, + }; +}; diff --git a/packages/ai-studio/AiGeneration/Studio/SafeTruncation.js b/packages/ai-studio/AiGeneration/Studio/SafeTruncation.js new file mode 100644 index 0000000..c1af87e --- /dev/null +++ b/packages/ai-studio/AiGeneration/Studio/SafeTruncation.js @@ -0,0 +1,68 @@ +// @flow + +/** + * Truncate text without splitting a user-perceived character. + * + * A plain `slice(0, maxLength)` cuts at a UTF-16 code-unit offset, which can + * fall between the two halves of an astral character (an emoji, or rare CJK — + * both realistic in GDevelop scene, object and variable names, and in the text + * a sub-agent writes back). The lone high surrogate left behind encodes to + * U+FFFD, so the replacement character reaches whatever the string feeds: a + * sub-agent prompt or the parent's transcript and hence the model. + * + * Surrogate pairs are only the common case: a cut can also land inside a + * multi-code-point grapheme (a ZWJ family 👨‍👩‍👧, a flag 🇫🇷, a base letter plus + * a combining mark), leaving a dangling joiner or accent. Rather than + * hand-rolling grapheme tables, this delegates to `Intl.Segmenter` (a + * platform API available everywhere the editor runs), with the old + * surrogate-pair step-back as the fallback for an environment without it. + * + * In its own module because both `FinalizeSubAgents` (the report cap) and + * `SpawnSubAgents` (the GDD context note cap) need it, and `FinalizeSubAgents` + * already imports from `SpawnSubAgents` — importing the other way would be a + * cycle. + */ +const getGraphemeEnds = ( + text: string +): Array<{| index: number, length: number |}> | null => { + try { + // `(global: any)` so Flow never needs an `Intl` libdef, and so a missing + // global (rather than a missing property) also falls back instead of + // throwing at module scope. `SpawnSubAgents` already reads `global.gd` + // the same way. + const GlobalIntl: any = (global: any).Intl; + if (!GlobalIntl || !GlobalIntl.Segmenter) return null; + const ends = []; + const iterator: any = new GlobalIntl.Segmenter(undefined, { + granularity: 'grapheme', + }).segment(text); + for (const part of iterator) { + ends.push({ index: part.index, length: part.segment.length }); + } + return ends; + } catch (error) { + return null; + } +}; + +export const truncateAtCodePointBoundary = ( + text: string, + maxLength: number +): string => { + if (text.length <= maxLength) return text; + const graphemes = getGraphemeEnds(text); + if (!graphemes) { + let end = maxLength; + const lastCode = text.charCodeAt(end - 1); + if (lastCode >= 0xd800 && lastCode <= 0xdbff) end -= 1; + return text.slice(0, end); + } + let end = 0; + for (const grapheme of graphemes) { + // A grapheme starting inside the budget but ending outside it is dropped + // whole: keeping it would exceed maxLength, splitting it corrupt it. + if (grapheme.index + grapheme.length > maxLength) break; + end = grapheme.index + grapheme.length; + } + return text.slice(0, end); +}; diff --git a/packages/ai-studio/AiGeneration/Studio/SpawnSubAgents.js b/packages/ai-studio/AiGeneration/Studio/SpawnSubAgents.js new file mode 100644 index 0000000..69917b1 --- /dev/null +++ b/packages/ai-studio/AiGeneration/Studio/SpawnSubAgents.js @@ -0,0 +1,277 @@ +// @flow + +import { makeSimplifiedProjectBuilder } from '../../EditorFunctions/SimplifiedProject/SimplifiedProject'; +import { customCreateSubAgentAiRequest } from '../../AI/CustomAIClient'; +import { isSpawnableRoleId, type StudioRoleId } from './Roles'; +import { + type AiRequest, + type AiRequestMessageAssistantFunctionCall, +} from '../../Utils/GDevelopServices/Generation'; +import { truncateAtCodePointBoundary } from './SafeTruncation'; + +const gd: libGDevelop = global.gd; + +/** Cap on live sub-agents one parent may have (W8). */ +export const MAX_SUB_AGENTS_PER_PARENT = 8; + +/** + * Live sub-agents of a parent: launched calls (stamped with + * `subAgentAiRequestId`) with no `function_call_output` for their call yet, + * whose child request still exists in storage. + * + * Finished children stay in storage (they are history), so counting every + * stored child turned the fan-out cap into a lifetime cap: after + * MAX_SUB_AGENTS_PER_PARENT spawns ever, no later task could ever be + * delegated again. Pruned or deleted children free their slot. + */ +export const countLiveSubAgentsForParent = ( + parentAiRequest: AiRequest, + aiRequests: { [string]: AiRequest } +): number => { + const output = (parentAiRequest && parentAiRequest.output) || []; + const answeredCallIds = new Set(); + const launchedChildren = new Map(); + output.forEach((message: any) => { + if (!message || typeof message !== 'object') return; + if ( + message.type === 'function_call_output' && + typeof message.call_id === 'string' + ) { + answeredCallIds.add(message.call_id); + return; + } + if ( + message.type === 'message' && + message.role === 'assistant' && + Array.isArray(message.content) + ) { + message.content.forEach((entry: any) => { + if (!entry || typeof entry !== 'object') return; + if ( + entry.type === 'function_call' && + typeof entry.subAgentAiRequestId === 'string' && + typeof entry.call_id === 'string' + ) { + launchedChildren.set(entry.subAgentAiRequestId, entry.call_id); + } + }); + } + }); + const requests = aiRequests || {}; + let live = 0; + launchedChildren.forEach((callId, childId) => { + if (!requests[childId]) return; + if (answeredCallIds.has(callId)) return; + live++; + }); + return live; +}; + +/** Whether a function call is the studio's delegation tool. */ +export const isSpawnAgentCall = ( + functionCall: AiRequestMessageAssistantFunctionCall +): boolean => functionCall.name === 'spawn_agent'; + +export type ParsedSpawnAgentArgs = {| + role: StudioRoleId, + shortTitle: string, + task: string, + context: string, + relatedTaskId: string | null, +|}; + +/** + * Read and validate a `spawn_agent` call's arguments. Null when the call is + * malformed: the caller then fails the call instead of spawning an agent with + * half an instruction. + */ +export const parseSpawnAgentArgs = ( + functionCall: AiRequestMessageAssistantFunctionCall +): ?ParsedSpawnAgentArgs => { + let parsedArguments: any; + try { + parsedArguments = JSON.parse(functionCall.arguments); + } catch (error) { + return null; + } + if ( + !parsedArguments || + typeof parsedArguments !== 'object' || + Array.isArray(parsedArguments) + ) { + return null; + } + + // The model's own JSON: each field is checked, and anything unexpected makes + // the whole call invalid (the caller then fails it instead of spawning with + // half an instruction). + const role: any = parsedArguments.role; + if (!isSpawnableRoleId(role)) return null; + + const rawShortTitle: any = parsedArguments.short_title; + const rawTask: any = parsedArguments.task; + const rawContext: any = parsedArguments.context; + const rawRelatedTaskId: any = parsedArguments.related_task_id; + + if (typeof rawShortTitle !== 'string') return null; + const shortTitle = rawShortTitle.trim(); + if (!shortTitle) return null; + + if (typeof rawTask !== 'string') return null; + const task = rawTask.trim(); + if (!task) return null; + + // Reject (do not coerce) a present-but-wrongly-typed optional field. + if (rawContext !== undefined && typeof rawContext !== 'string') return null; + if (rawRelatedTaskId !== undefined && typeof rawRelatedTaskId !== 'string') + return null; + + const context = typeof rawContext === 'string' ? rawContext.trim() : ''; + const relatedTaskId = + typeof rawRelatedTaskId === 'string' && rawRelatedTaskId.trim() + ? rawRelatedTaskId.trim() + : null; + + // Reject oversized strings (W6): a runaway argument must not become a child + // request the user cannot read. + if (shortTitle.length > 100 || task.length > 4000 || context.length > 4000) { + return null; + } + + return { role, shortTitle, task, context, relatedTaskId }; +}; + +/** + * The `GDD_*` project variables, as a note appended to a sub-agent's context. + * + * The designer writes the design document as project variables (it is the only + * artifact a later role can read), so the developer and the tester are handed + * them explicitly rather than having to discover them. + * + * Project variables live at `globalVariables` on the simplified project (the + * `properties` object holds resolution and orientation, not variables). + */ +export const buildGddContextNote = (project: ?gdProject): string => { + if (!project) return ''; + let simplifiedProject: any; + try { + simplifiedProject = makeSimplifiedProjectBuilder(gd).getSimplifiedProject( + project, + {} + ); + } catch (error) { + // A project the simplifier cannot read is not a reason to refuse to spawn: + // the agent simply gets no design note. + console.warn( + 'Unable to read the project variables for a sub-agent:', + error + ); + return ''; + } + + const projectVariables: Array = + (simplifiedProject && simplifiedProject.globalVariables) || []; + const gddVariables = projectVariables.filter( + variable => + variable && + typeof variable.variableName === 'string' && + variable.variableName.startsWith('GDD_') + ); + if (gddVariables.length === 0) { + return 'No GDD_ project variable is set on this project yet: the design document has not been written.'; + } + + const lines = gddVariables.map( + variable => + `- ${variable.variableName}: ${ + typeof variable.value === 'string' + ? variable.value + : JSON.stringify(variable.variableChildren || variable.type) + }` + ); + const note = `The studio's design document, as GDD_ project variables:\n${lines.join( + '\n' + )}`; + return ( + truncateAtCodePointBoundary(note, 4000) + + (note.length > 4000 ? '\n…(truncated)' : '') + ); +}; + +export type SpawnSubAgentResult = + | {| + subAgentAiRequestId: string, + shortTitle: string, + callId: string, + /** + * The plan task the spawn named (`related_task_id`), so the caller can + * stamp `functionCall.taskId` and the chat can render the call inside its + * plan task row. `null` when the model delegated without a task. + */ + relatedTaskId: string | null, + |} + | {| error: string |}; + +/** + * Create the child `AiRequest` for one `spawn_agent` call. + * + * The caller is responsible for stamping `subAgentAiRequestId` onto the call + * (see `onStamped`) and for activating the sub-agent; this function only creates + * the request, so it stays testable without the React context. + */ +export const spawnSubAgent = async ({ + parentAiRequestId, + functionCall, + gameProjectJson, + projectSpecificExtensionsSummaryJson, + project, + gddContextNote, + onStamped, +}: {| + parentAiRequestId: string, + functionCall: AiRequestMessageAssistantFunctionCall, + gameProjectJson: string | null, + projectSpecificExtensionsSummaryJson: string | null, + project: ?gdProject, + // The GDD note, computed once per batch and passed in (W7). When provided it + // is used verbatim instead of rebuilding the simplified project per call. + gddContextNote?: string | null, + onStamped: (subAgentAiRequestId: string) => void, +|}): Promise => { + const parsedArgs = parseSpawnAgentArgs(functionCall); + if (!parsedArgs) { + return { error: 'Invalid spawn_agent arguments.' }; + } + + let userRequest = `Task: ${parsedArgs.task}`; + if (parsedArgs.context) { + userRequest += `\n\nContext: ${parsedArgs.context}`; + } + + // The developer and the tester need the design document; the designer writes + // it, so handing it back to the designer would be noise. + const spawnContextNote = + parsedArgs.role === 'developer' || parsedArgs.role === 'tester' + ? gddContextNote != null + ? gddContextNote + : buildGddContextNote(project) + : null; + + const subAgentRequest = await customCreateSubAgentAiRequest({ + parentAiRequestId, + roleId: parsedArgs.role, + userRequest, + gameProjectJson, + projectSpecificExtensionsSummaryJson, + spawnContextNote, + }); + + onStamped(subAgentRequest.id); + + return { + subAgentAiRequestId: subAgentRequest.id, + shortTitle: parsedArgs.shortTitle, + callId: functionCall.call_id, + relatedTaskId: parsedArgs.relatedTaskId, + }; +}; diff --git a/packages/ai-studio/AiGeneration/Studio/UseStudioNudge.js b/packages/ai-studio/AiGeneration/Studio/UseStudioNudge.js new file mode 100644 index 0000000..f94a43c --- /dev/null +++ b/packages/ai-studio/AiGeneration/Studio/UseStudioNudge.js @@ -0,0 +1,159 @@ +// @flow + +import * as React from 'react'; +import { type AiRequest } from '../../Utils/GDevelopServices/Generation'; +import { type EditorFunctionCallResult } from '../../EditorFunctions'; +import { + aiRequestHasWorkInProgress, + getLatestActivePlan, + getPendingSubAgentFunctionCalls, +} from '../AiRequestUtils'; +import { getNextReadyTask, type StudioPlanTask } from './PlanStore'; +import { + shouldNudgeStudioManager, + getActionablePlanSignature, + buildStudioNudgeMessage, +} from './NudgePolicy'; + +type ActiveSubAgent = {| + parentAiRequestId: string, + callId: string, +|}; + +/** + * How long the manager must stay stalled before the nudge is sent. A grace + * period, not a poll interval: the effect is re-run (and the timer cleared) on + * every relevant state change, so this only has to outlast the sub-second gap + * between writing a tool result and the follow-up turn starting. + */ +export const STUDIO_NUDGE_GRACE_MS = 5000; + +/** Hard cap on nudges for one request, so a pathological model cannot loop. */ +export const MAX_STUDIO_NUDGES_PER_REQUEST = 5; + +/** + * Only a request touched within this window may be nudged. Opening an old, + * abandoned chat must not resurrect its plan and spend tokens; the window is + * generous enough that a genuinely-stalled live run is always in range. + */ +export const STUDIO_NUDGE_MAX_AGE_MS = 10 * 60 * 1000; + +/** + * Keep the local studio's manager moving. + * + * Under BYOK nothing resumes a manager that produced a plan and then ended its + * turn without delegating: the multi-agent loop waits forever. This hook sends + * at most one continuation per stalled plan state (`NudgePolicy` decides when), + * with a short grace period so it never races a turn already in flight. + * + * It is a no-op for the hosted path and for sub-agent requests (only the + * top-level, non-role request is nudged) and for a user-stopped request. + */ +export const useStudioNudge = ({ + selectedAiRequest, + aiRequests, + activeSubAgents, + getEditorFunctionCallResults, + isSendingAiRequest, + onSendMessage, +}: {| + selectedAiRequest: ?AiRequest, + aiRequests: { [string]: AiRequest }, + activeSubAgents: { [string]: ActiveSubAgent }, + getEditorFunctionCallResults: string => Array | null, + isSendingAiRequest: (aiRequestId: string | null) => boolean, + onSendMessage: (options: Object) => Promise, +|}): void => { + // Per-request: the plan state already nudged, and how many nudges were sent. + const nudgedSignatureByRequestRef = React.useRef>( + new Map() + ); + const nudgedCountByRequestRef = React.useRef>(new Map()); + + React.useEffect( + () => { + if (!selectedAiRequest) return; + const aiRequestId = selectedAiRequest.id; + + const isTopLevel = + !selectedAiRequest.parentAiRequestId && !selectedAiRequest.studioRoleId; + if (!isTopLevel) return; + + const plan = getLatestActivePlan(selectedAiRequest); + const tasks: Array = plan && plan.tasks ? plan.tasks : []; + const planSignature = getActionablePlanSignature(tasks); + const liveSubAgentCount = Object.keys(activeSubAgents).filter( + subAgentId => + activeSubAgents[subAgentId] && + activeSubAgents[subAgentId].parentAiRequestId === aiRequestId + ).length; + + const updatedAtMs = Date.parse(selectedAiRequest.updatedAt || ''); + const isRecent = + Number.isFinite(updatedAtMs) && + Date.now() - updatedAtMs <= STUDIO_NUDGE_MAX_AGE_MS; + + const shouldNudge = shouldNudgeStudioManager({ + isTopLevel, + isRecent, + status: selectedAiRequest.status, + hasWorkInProgress: aiRequestHasWorkInProgress( + selectedAiRequest, + getEditorFunctionCallResults(aiRequestId) + ), + isSending: isSendingAiRequest(aiRequestId), + liveSubAgentCount, + pendingSubAgentCallCount: getPendingSubAgentFunctionCalls({ + aiRequest: selectedAiRequest, + }).length, + planSignature, + previouslyNudgedSignature: + nudgedSignatureByRequestRef.current.get(aiRequestId) || null, + }); + if (!shouldNudge || !planSignature) return; + + const nudgedCount = nudgedCountByRequestRef.current.get(aiRequestId) || 0; + if (nudgedCount >= MAX_STUDIO_NUDGES_PER_REQUEST) return; + + const nextReadyTask = getNextReadyTask(tasks); + const timer = setTimeout(() => { + // Mark before the send: a re-render landing during the await must not + // arm a second nudge for the same plan state. + nudgedSignatureByRequestRef.current.set(aiRequestId, planSignature); + nudgedCountByRequestRef.current.set(aiRequestId, nudgedCount + 1); + console.info( + `[studio] nudging the stalled manager #${aiRequestId} for plan ${planSignature}.` + ); + // `onSendMessage` can return undefined (its own early-return path), so it + // is not necessarily a promise and may also throw synchronously. + Promise.resolve() + .then(() => + onSendMessage({ + aiRequestId, + userMessage: buildStudioNudgeMessage(nextReadyTask), + }) + ) + .catch(error => { + console.error('[studio] error while nudging the manager:', error); + // Let the next pass retry the same state. + if ( + nudgedSignatureByRequestRef.current.get(aiRequestId) === + planSignature + ) { + nudgedSignatureByRequestRef.current.delete(aiRequestId); + } + }); + }, STUDIO_NUDGE_GRACE_MS); + + return () => clearTimeout(timer); + }, + [ + selectedAiRequest, + aiRequests, + activeSubAgents, + getEditorFunctionCallResults, + isSendingAiRequest, + onSendMessage, + ] + ); +}; diff --git a/packages/ai-studio/AiGeneration/Studio/UseStudioRuntime.js b/packages/ai-studio/AiGeneration/Studio/UseStudioRuntime.js new file mode 100644 index 0000000..4001540 --- /dev/null +++ b/packages/ai-studio/AiGeneration/Studio/UseStudioRuntime.js @@ -0,0 +1,302 @@ +// @flow + +import * as React from 'react'; +import { type AiRequest } from '../../Utils/GDevelopServices/Generation'; +import { type EditorFunctionCallResult } from '../../EditorFunctions'; +import { + getAllSubAgentFunctionCalls, + getLatestActivePlan, +} from '../AiRequestUtils'; +import { + isSubAgentFinished, + buildSubAgentReport, + getSubAgentReportLabel, + getSubAgentRoleId, + parentHasOutputForCall, +} from './FinalizeSubAgents'; +import { + patchPlanTask, + getNextReadyTask, + replacePlanMessage, + type StudioPlanTask, +} from './PlanStore'; +import { isSpawnAgentCall, parseSpawnAgentArgs } from './SpawnSubAgents'; + +type ActiveSubAgent = {| + parentAiRequestId: string, + callId: string, +|}; + +/** + * How many sub-agents this hook may finalize in one pass. The pass runs on every + * render, so an unbounded loop over a large fleet would stall the chat. + */ +const MAX_FINALIZATIONS_PER_PASS = 8; + +/** + * Flip the plan task a spawn call named to `done`, and the next ready task to + * `in_progress`. + * + * The plan message already in the parent's output is rewritten in place - same + * `call_id`, new JSON - rather than appended to: the plan tool call's own result + * is that message, and `getLatestActivePlan` reads the last output carrying + * `.plan.tasks`, so rewriting it keeps the transcript consistent and needs no + * new tool call the model never made. + * + * The tasks are patched, not rebuilt: rebuilding strips the fields the writer + * does not model (see `PlanStore.patchPlanTask`). + * + * Returns the new output array, or null when there is nothing to change. + */ +export const buildPlanStatusUpdateOutput = ( + parentRequest: AiRequest, + callId: string +): ?Array => { + const plan = getLatestActivePlan(parentRequest); + if (!plan || !Array.isArray(plan.tasks)) return null; + const tasks: Array = (plan.tasks: any); + if (tasks.length === 0) return null; + + const spawnCall = getAllSubAgentFunctionCalls({ + aiRequest: parentRequest, + }).find(functionCall => functionCall.call_id === callId); + if (!spawnCall || !isSpawnAgentCall(spawnCall)) return null; + const parsedArgs = parseSpawnAgentArgs(spawnCall); + const relatedTaskId = parsedArgs ? parsedArgs.relatedTaskId : null; + if (!relatedTaskId) return null; + if (!tasks.some(task => task && task.id === relatedTaskId)) return null; + + let patchedTasks = patchPlanTask(tasks, relatedTaskId, { status: 'done' }); + const nextReadyTask = getNextReadyTask(patchedTasks); + if (nextReadyTask) { + patchedTasks = patchPlanTask(patchedTasks, nextReadyTask.id, { + status: 'in_progress', + }); + } + + return replacePlanMessage(parentRequest.output || [], patchedTasks); +}; + +/** + * The studio runtime: closes the loop between a spawned sub-agent and its parent. + * + * A child `AiRequest` is polled and driven by the shared machinery + * (`AiRequestContext` + `useProcessFunctionCalls`), exactly like a hosted + * sub-agent. What is missing under BYOK is the write-back: the hosted backend + * posts a `function_call_output` for the sub-agent's call when its sub-agent is + * done, and the client only reacts to it. This hook does that write locally - + * + * 1. wait until the child has nothing left to do (`isSubAgentFinished`); + * 2. flip the plan task the spawn call named to `done`, and the next ready task + * to `in_progress`, patching the raw tasks so nothing else is lost; + * 3. post the child's report as its call's `function_call_output`, which the + * shared BYOK path turns into the parent's next model turn and which + * `removeSubAgentIfDone` reads to retire the child. + * + * A sub-agent cannot spawn a sub-agent: `spawn_agent` is not in any sub-agent + * role's tool subset (`Roles.getToolsForRole`), so the recursion depth is one. + */ +export const useStudioRuntime = ({ + aiRequests, + activeSubAgents, + getEditorFunctionCallResults, + updateAiRequest, + onSendEditorFunctionCallResults, + enqueueRequestWrite, +}: {| + aiRequests: { [string]: AiRequest }, + activeSubAgents: { [string]: ActiveSubAgent }, + getEditorFunctionCallResults: string => Array | null, + updateAiRequest: ( + aiRequestId: string, + updateFn: (previousAiRequest: ?AiRequest) => AiRequest + ) => void, + onSendEditorFunctionCallResults: ( + aiRequestId: string, + editorFunctionCallResults: Array, + options: Object + ) => Promise, + /** + * Runs a write once the request is free (see `RequestWriteGate`). Optional so + * the hook can be used without the gate; the write then runs immediately. + */ + enqueueRequestWrite?: ( + aiRequestId: string, + write: () => Promise + ) => Promise, +|}): void => { + // Call ids already finalized in this session. Without it, a render landing + // between writing the output and the parent's state propagating would finalize + // the same agent twice, and the parent would take two turns for one agent. + const finalizedCallIdsRef = React.useRef>(new Set()); + // The pass awaits a message send, so a second pass can start before the first + // finished. Serialize them. + const isRunningRef = React.useRef(false); + + React.useEffect( + () => { + if (isRunningRef.current) return; + + const subAgentIds = Object.keys(activeSubAgents); + if (subAgentIds.length === 0) return; + + const pendingFinalizations: Array<{| + subAgentRequest: AiRequest, + parentAiRequestId: string, + callId: string, + |}> = []; + + for (const subAgentId of subAgentIds) { + if (pendingFinalizations.length >= MAX_FINALIZATIONS_PER_PASS) break; + const subAgentInfo = activeSubAgents[subAgentId]; + if (!subAgentInfo) continue; + // Already finalized in an earlier pass. + if (finalizedCallIdsRef.current.has(subAgentInfo.callId)) continue; + + const subAgentRequest = aiRequests[subAgentId]; + if (!subAgentRequest) continue; + // A hosted sub-agent is retired by the server, never finalized here. + if (!getSubAgentRoleId({ aiRequest: subAgentRequest, aiRequests })) { + continue; + } + const parentRequest = aiRequests[subAgentInfo.parentAiRequestId]; + if (!parentRequest) continue; + + // The parent already carries this call's output: the child is on its way + // out (`removeSubAgentIfDone` is what removes it). Stay compatible. + const parentAlreadyHasOutput = parentHasOutputForCall( + parentRequest, + subAgentInfo.callId + ); + if (parentAlreadyHasOutput) continue; + + if ( + !isSubAgentFinished({ + subAgentRequest, + editorFunctionCallResults: getEditorFunctionCallResults(subAgentId), + }) + ) { + continue; + } + + pendingFinalizations.push({ + subAgentRequest, + parentAiRequestId: subAgentInfo.parentAiRequestId, + callId: subAgentInfo.callId, + }); + } + + if (pendingFinalizations.length === 0) return; + + isRunningRef.current = true; + (async () => { + const startedCallIds: Array = []; + try { + for (const { + subAgentRequest, + parentAiRequestId, + callId, + } of pendingFinalizations) { + // Mark it before any await: a pass landing during the await must not + // pick the same call up again. + finalizedCallIdsRef.current.add(callId); + startedCallIds.push(callId); + if (finalizedCallIdsRef.current.size > 500) { + // Bound the guard like the other in-flight sets of the AI path. + finalizedCallIdsRef.current.clear(); + finalizedCallIdsRef.current.add(callId); + } + console.info( + `[studio] finalizing sub-agent #${ + subAgentRequest.id + } for call ${callId}.` + ); + + const parentRequest = aiRequests[parentAiRequestId]; + if (parentRequest) { + const writePlan = async () => { + updateAiRequest(parentAiRequestId, currentRequest => { + const base = + currentRequest || + ({ + id: parentAiRequestId, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + userId: 'local-byok-user', + status: 'ready', + error: null, + output: [], + }: any); + // Recompute inside the updater: two finalizations in one pass + // would otherwise write the first snapshot twice. + const updated = buildPlanStatusUpdateOutput(base, callId); + return updated ? { ...base, output: updated } : base; + }); + }; + if (enqueueRequestWrite) { + // Fire-and-forget: a blocked gate write no longer settles + // inline, so awaiting it would freeze every later finalization + // behind `isRunningRef`. + enqueueRequestWrite(parentAiRequestId, writePlan).catch( + error => { + if (error.code === 'superseded') { + console.info('[studio] plan write superseded'); + } else { + console.error( + '[studio] error while writing the plan update:', + error + ); + } + } + ); + } else { + await writePlan(); + } + } + + const label = getSubAgentReportLabel({ + aiRequest: subAgentRequest, + aiRequests, + }); + const sent = await onSendEditorFunctionCallResults( + parentAiRequestId, + [ + { + status: 'finished', + call_id: callId, + success: true, + output: { + message: `Report from the ${label}:\n\n${buildSubAgentReport( + subAgentRequest, + aiRequests + )}`, + }, + }, + ], + {} + ); + // Report delivery is not droppable: throw so the `catch` retries + // (it deletes `startedCallIds`, so the next pass redoes this). + if (!sent) throw new Error('Sub-agent report was not sent.'); + } + } catch (error) { + console.error('[studio] error while finalizing a sub-agent:', error); + // Let the next pass retry: a failed write must not be marked done. + startedCallIds.forEach(callId => + finalizedCallIdsRef.current.delete(callId) + ); + } finally { + isRunningRef.current = false; + } + })(); + }, + [ + aiRequests, + activeSubAgents, + getEditorFunctionCallResults, + updateAiRequest, + onSendEditorFunctionCallResults, + enqueueRequestWrite, + ] + ); +}; diff --git a/packages/ai-studio/MANIFEST.json b/packages/ai-studio/MANIFEST.json new file mode 100644 index 0000000..6f715af --- /dev/null +++ b/packages/ai-studio/MANIFEST.json @@ -0,0 +1,100 @@ +{ + "donor": "/home/john/Projects/GDevelop-Unlocked/review/GDevelop-Heretek", + "files": { + "AI/CustomAIClient.js": { + "donor": "newIDE/app/src/AI/CustomAIClient.js", + "bytes": 208828, + "sha256": "2dd976b936908115d08374851121a077d2b2b6c314a029a4bc52ab7125f51723" + }, + "AI/CustomAIClient.spec.js": { + "donor": "newIDE/app/src/AI/CustomAIClient.spec.js", + "bytes": 257794, + "sha256": "0152072739b6590f004e886b8a5787cd769a529f044f14e48ca7e78ecc2a560a" + }, + "AiGeneration/Studio/EditApprovalLabel.js": { + "donor": "newIDE/app/src/AiGeneration/Studio/EditApprovalLabel.js", + "bytes": 1308, + "sha256": "a924efc714123ab45e7d649d581b4d54557e6452fc0df3bcf8ad387c27c9f194" + }, + "AiGeneration/Studio/FinalizeSubAgents.js": { + "donor": "newIDE/app/src/AiGeneration/Studio/FinalizeSubAgents.js", + "bytes": 9111, + "sha256": "708de36210d000af56e795280eaa5dd450e32cedb4a54b7b031353a6cc4a679f" + }, + "AiGeneration/Studio/LoopGuard.js": { + "donor": "newIDE/app/src/AiGeneration/Studio/LoopGuard.js", + "bytes": 9401, + "sha256": "0ad4c6f5d6312a38f56e0385b8408adec72634c95d7252a3ff193af1e667101e" + }, + "AiGeneration/Studio/NudgePolicy.js": { + "donor": "newIDE/app/src/AiGeneration/Studio/NudgePolicy.js", + "bytes": 3821, + "sha256": "c13a8af43b3b3d23ccb2dff2bc3438755d502090fd99f2bad1061d86f8d3c925" + }, + "AiGeneration/Studio/PlanStore.js": { + "donor": "newIDE/app/src/AiGeneration/Studio/PlanStore.js", + "bytes": 13166, + "sha256": "3218ec230b6786d9105fd2854b3389b20b6c68a0889ccc6134f533106b1a3be6" + }, + "AiGeneration/Studio/RequestWriteGate.js": { + "donor": "newIDE/app/src/AiGeneration/Studio/RequestWriteGate.js", + "bytes": 5904, + "sha256": "bcd78ad8d0a22ca58ea413b2d1bbdbd07814184a0e28726dcfe8d466707c5064" + }, + "AiGeneration/Studio/Roles.js": { + "donor": "newIDE/app/src/AiGeneration/Studio/Roles.js", + "bytes": 12493, + "sha256": "dac71987229c63fcea55fabd049b43ab48beff68cb19813de9d1c77090c6798f" + }, + "AiGeneration/Studio/RoleToolPolicy.js": { + "donor": "newIDE/app/src/AiGeneration/Studio/RoleToolPolicy.js", + "bytes": 2097, + "sha256": "66a7d577f12fccaa1b23d89bc7ba02c9bca07058358cc53d3ecfe50d604ef1b8" + }, + "AiGeneration/Studio/SafeTruncation.js": { + "donor": "newIDE/app/src/AiGeneration/Studio/SafeTruncation.js", + "bytes": 2743, + "sha256": "24e4e11fb5ee360374cd3bfa092fd01755b99228c34df74547473336d4a799ce" + }, + "AiGeneration/Studio/SpawnSubAgents.js": { + "donor": "newIDE/app/src/AiGeneration/Studio/SpawnSubAgents.js", + "bytes": 9089, + "sha256": "ffc0658c57b027d20cc50c88bc350ac013369c3fb09ed366ff8736a7aa78d2ae" + }, + "AiGeneration/Studio/UseStudioNudge.js": { + "donor": "newIDE/app/src/AiGeneration/Studio/UseStudioNudge.js", + "bytes": 5824, + "sha256": "12df5f3f54a52e3c2c72646e6825360206eec0521dd6f15d73cd39660b21e0d2" + }, + "AiGeneration/Studio/UseStudioRuntime.js": { + "donor": "newIDE/app/src/AiGeneration/Studio/UseStudioRuntime.js", + "bytes": 11187, + "sha256": "ecc51b28d00baf1ccd7d38d542e4a9237cd145cd863586c6fae6e587d03564c3" + }, + "dev-harness/mock-ai-provider.js": { + "donor": "scripts/dev/mock-ai-provider.js", + "bytes": 9858, + "sha256": "8840bec1a98385c106deaa78aaec5b18fdf98b54a944fc9413ee5916679150ca" + }, + "dev-harness/mock-script.example.json": { + "donor": "scripts/dev/mock-script.example.json", + "bytes": 887, + "sha256": "45c2915841b0a6c0f17db7a297a39ca1dfb8b889d74f018f87d1815cadf17c26" + }, + "dev-harness/mock-script-multi.json": { + "donor": "scripts/dev/mock-script-multi.json", + "bytes": 1314, + "sha256": "c94da74ac1251e07ae7a4576ea7354a1ef9f76a9294cca4bf473c0807d5ed824" + }, + "dev-harness/mock-script-specialists.json": { + "donor": "scripts/dev/mock-script-specialists.json", + "bytes": 960, + "sha256": "31fe97f889935e92bfb3af5056e2579105fb7b48b325fd3dfcc9a5847b4ee6b7" + }, + "dev-harness/mock-child-scripts.example.json": { + "donor": "scripts/dev/mock-child-scripts.example.json", + "bytes": 532, + "sha256": "8f785710966f26a7923793f47a785b18d8e8d326bb381f78682a7eb50ac6bdd7" + } + } +} diff --git a/packages/ai-studio/README.md b/packages/ai-studio/README.md new file mode 100644 index 0000000..5066c16 --- /dev/null +++ b/packages/ai-studio/README.md @@ -0,0 +1,40 @@ +# packages/ai-studio — Tier-2 extraction (Phase 4, issue #4) + +~8.9k-line BYOK AI + multi-agent Studio, moved **verbatim** out of the +fork. The fork is a donor, not a delivery vehicle. Never hand-edit +files under `AI/` or `AiGeneration/` here — re-run the extractor: + +```bash +node scripts/extract-tier2.mjs --fork +``` + +`MANIFEST.json` pins every file (sha256); `tests/tier2/extract.test.mjs` +fails if any byte drifts from the donor. + +## Contents + +| Path | Donor | Notes | +|---|---|---| +| `AI/CustomAIClient.js` | `newIDE/app/src/AI/CustomAIClient.js` (5870 lines) | OpenAI-compatible client, 39-tool `GDEVELOP_OPENAI_TOOLS`, SSE streaming, `gd-custom-ai-*` localStorage keys. **Zero `api.gdevelop.io` references** (pinned by test) | +| `AI/CustomAIClient.spec.js` | co-located spec | Run inside `newIDE/app` (`react-app-rewired test`), not here | +| `AiGeneration/Studio/*.js` (12 prod) | `AiGeneration/Studio/` (2371 LOC) | Roles, PlanStore, LoopGuard, SpawnSubAgents, FinalizeSubAgents, UseStudioRuntime, UseStudioNudge, NudgePolicy, RequestWriteGate, RoleToolPolicy, SafeTruncation, EditApprovalLabel | +| `dev-harness/mock-ai-provider.js` | `scripts/dev/mock-ai-provider.js` | Committed OpenAI-compatible mock for offline E2E (+ `mock-script*.json` fixtures) | + +## Wiring + +Import rewiring is limited to `packages/unlock-shim/generation-shim.mjs` +(routing predicates + fail-closed leak guard) plus the generated glue +patches `07-generation-glue.patch` (Generation.js branches) and +`08-preferences-ai.patch` (`ai` prefs tab + storage). The extracted +modules themselves are byte-identical to the donor. + +## Desktop E2E (requires a built app — procedure, not automation) + +1. Apply all patches to a checkout (`node scripts/apply-patches.mjs --target …`). +2. Start the offline harness: `node packages/ai-studio/dev-harness/mock-ai-provider.js` + (or real Ollama at `http://localhost:11434/v1`; set `ORIGINS` for CORS). +3. In Preferences → AI, set Base URL to the harness, complete a generation. +4. Assert: subagent spawn/finalize loop runs (Roles/PlanStore/LoopGuard + paths); **zero requests to `api.gdevelop.io/generation`** in BYOK mode + (the hook logs spoofed usage calls in `window.__HERETEK__.spoofedRequests`; + generation traffic is observable in the harness log). diff --git a/packages/ai-studio/dev-harness/mock-ai-provider.js b/packages/ai-studio/dev-harness/mock-ai-provider.js new file mode 100644 index 0000000..777fced --- /dev/null +++ b/packages/ai-studio/dev-harness/mock-ai-provider.js @@ -0,0 +1,289 @@ +#!/usr/bin/env node +'use strict'; + +/** + * A tiny OpenAI-compatible mock provider for the BYOK / multi-agent harness. + * + * Lets the editor's AI path be exercised end-to-end without credentials: the + * @feedback-loop skill and manual WebUI audits can point the editor at this + * server and get deterministic replies, while the routing/telemetry code paths + * run exactly as they do against a real gateway. + * + * node scripts/dev/mock-ai-provider.js # listens on :11435 + * PORT=11500 node scripts/dev/mock-ai-provider.js + * + * Then set the editor's custom AI base URL to http://localhost:11435/v1. + * + * It streams (`stream: true`) and answers non-streamed requests, and emits + * `x-omniroute-*` telemetry headers. Two details matter and are easy to get + * wrong in a real proxy too: + * - the response must be permissive CORS (the editor is served from :3000); + * - custom response headers are invisible to browser JS unless listed in + * `Access-Control-Expose-Headers`. + * Without the latter the editor cannot read the telemetry at all. + * + * No API key, no outbound network, no persistence: safe to leave running while + * developing. + */ + +const http = require('http'); + +const PORT = Number(process.env.PORT) || 11435; +const MODEL = process.env.MODEL || 'mock-city-builder'; + +const CORS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'content-type, authorization', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Expose-Headers': + 'x-omniroute-model, x-omniroute-latency-ms, x-omniroute-tokens-in, x-omniroute-tokens-out, x-omniroute-cache', +}; + +/** + * Optional deterministic script: a JSON array of turns, consumed one per + * request (the last turn repeats). Each turn is either + * { "content": "..." } a plain answer, or + * { "content": "...", "toolCalls": [ {"id","name","arguments"} ] } + * with `arguments` a JSON string (or object) as the OpenAI API expects. Set + * `MOCK_SCRIPT=/path/to/script.json` to drive a fixed multi-step run (e.g. + * plan -> spawn_agent -> report) through the harness without a real model. + */ +const loadScript = () => { + const path = process.env.MOCK_SCRIPT; + if (!path) return null; + try { + const parsed = JSON.parse(require('fs').readFileSync(path, 'utf8')); + return Array.isArray(parsed) && parsed.length > 0 ? parsed : null; + } catch (error) { + console.error('MOCK_SCRIPT could not be read:', error.message); + return null; + } +}; + +const script = loadScript(); +let requestCount = 0; + +const nextTurn = () => { + if (!script) return null; + const turn = script[Math.min(requestCount, script.length - 1)]; + requestCount += 1; + return turn; +}; + +/** + * Optional PER-ROLE child scripts: MOCK_CHILD_SCRIPTS points at a JSON object + * { "designer": [turns...], "developer": [...], "tester": [...] }. A sub-agent + * request (its system prompt names a studio role) is matched to its role and + * gets that role's turns, consumed per role; a role with no script gets a plain + * reply. This lets a deterministic run script what each SPECIALIST does (e.g. a + * tool call) instead of one shared sequence. + */ +const loadChildScripts = () => { + const path = process.env.MOCK_CHILD_SCRIPTS; + if (!path) return null; + try { + const parsed = JSON.parse(require('fs').readFileSync(path, 'utf8')); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed + : null; + } catch (error) { + console.error('MOCK_CHILD_SCRIPTS could not be read:', error.message); + return null; + } +}; + +const childScripts = loadChildScripts(); +const childCounters = {}; + +const detectRole = messages => { + const system = messages.find( + m => m && m.role === 'system' && typeof m.content === 'string' + ); + const prompt = system ? system.content.toLowerCase() : ''; + if (prompt.includes('the designer')) return 'designer'; + if (prompt.includes('the developer')) return 'developer'; + if (prompt.includes('the qa tester')) return 'tester'; + return null; +}; + +const nextChildTurn = messages => { + if (!childScripts) return null; + const role = detectRole(messages); + const turns = role ? childScripts[role] : null; + if (!Array.isArray(turns) || turns.length === 0) return null; + const index = childCounters[role] || 0; + childCounters[role] = index + 1; + return turns[Math.min(index, turns.length - 1)]; +}; + +const json = (res, status, body, extraHeaders) => { + res.writeHead( + status, + Object.assign({ 'Content-Type': 'application/json' }, CORS, extraHeaders || {}) + ); + res.end(JSON.stringify(body)); +}; + +// A sub-agent request carries a studio ROLE prompt ("... the developer of a +// small game studio"). The top-level manager ALSO names "small game studio" (its +// role prompt became the manager's in the editor's role fix), so the reliable +// discriminator is a detected ROLE, not the shared phrase. +const isSubAgentRequest = messages => detectRole(messages) !== null; + +const buildReply = (messages, rawBody) => { + const lastUser = [...messages].reverse().find(m => m && m.role === 'user'); + const text = + lastUser && typeof lastUser.content === 'string' ? lastUser.content : ''; + return ( + 'Mock provider online. I received ' + + messages.length + + ' messages. Your last request starts: "' + + text.slice(0, 120).replace(/\s+/g, ' ') + + '". In a real run I would call create_or_update_plan and spawn_agent.' + ); +}; + +const telemetry = (rawBody, reply) => ({ + 'x-omniroute-model': MODEL, + 'x-omniroute-latency-ms': '42', + 'x-omniroute-tokens-in': String(Math.ceil(rawBody.length / 4)), + 'x-omniroute-tokens-out': String(Math.ceil(reply.length / 4)), + 'x-omniroute-cache': 'MISS', +}); + +const server = http.createServer((req, res) => { + if (req.method === 'OPTIONS') { + res.writeHead(204, CORS); + return res.end(); + } + + let rawBody = ''; + req.on('data', chunk => { + rawBody += chunk; + }); + req.on('end', () => { + if (req.method === 'GET' && req.url.startsWith('/v1/models')) { + return json(res, 200, { + object: 'list', + data: [{ id: MODEL, object: 'model', owned_by: 'mock' }], + }); + } + if (req.method === 'POST' && req.url.startsWith('/v1/chat/completions')) { + let payload = {}; + try { + payload = JSON.parse(rawBody || '{}'); + } catch (ignored) { + // A malformed body still gets a deterministic reply. + } + const messages = Array.isArray(payload.messages) ? payload.messages : []; + const isChild = isSubAgentRequest(messages); + const turn = isChild ? nextChildTurn(messages) : nextTurn(); + const reply = + turn && typeof turn.content === 'string' + ? turn.content + : buildReply(messages, rawBody); + const scriptedToolCalls = + turn && Array.isArray(turn.toolCalls) ? turn.toolCalls : []; + const toolCalls = scriptedToolCalls.map((call, index) => ({ + id: call.id || `call_${index}`, + type: 'function', + function: { + name: call.name, + arguments: + typeof call.arguments === 'string' + ? call.arguments + : JSON.stringify(call.arguments || {}), + }, + })); + const headers = telemetry(rawBody, reply); + + if (payload.stream === true) { + res.writeHead( + 200, + Object.assign({ 'Content-Type': 'text/event-stream' }, CORS, headers) + ); + const chunks = reply.match(/.{1,24}/g) || (reply ? [reply] : []); + // MOCK_STREAM_DELAY_MS spaces out chunks so UI that only shows while a + // stream is live (e.g. the streaming token counter) can be observed. + const streamDelayMs = Number(process.env.MOCK_STREAM_DELAY_MS) || 0; + const writeChunk = index => { + if (index >= chunks.length) return sendTail(); + res.write( + 'data: ' + + JSON.stringify({ choices: [{ delta: { content: chunks[index] } }] }) + + '\n\n' + ); + if (streamDelayMs > 0) { + setTimeout(() => writeChunk(index + 1), streamDelayMs); + } else { + writeChunk(index + 1); + } + }; + const sendTail = () => { + if (toolCalls.length > 0) { + res.write( + 'data: ' + + JSON.stringify({ + choices: [ + { + delta: { + tool_calls: toolCalls.map((call, index) => ({ + index, + id: call.id, + function: call.function, + })), + }, + }, + ], + }) + + '\n\n' + ); + } + res.write( + 'data: ' + + JSON.stringify({ + choices: [ + { + delta: {}, + finish_reason: toolCalls.length > 0 ? 'tool_calls' : 'stop', + }, + ], + }) + + '\n\n' + ); + res.write('data: [DONE]\n\n'); + return res.end(); + }; + writeChunk(0); + return; + } + + const message = { role: 'assistant', content: reply }; + if (toolCalls.length > 0) message.tool_calls = toolCalls; + return json( + res, + 200, + { + id: 'mock-1', + object: 'chat.completion', + model: MODEL, + choices: [ + { + index: 0, + message, + finish_reason: toolCalls.length > 0 ? 'tool_calls' : 'stop', + }, + ], + }, + headers + ); + } + json(res, 404, { error: { message: 'not found: ' + req.url } }); + }); +}); + +server.listen(PORT, '127.0.0.1', () => { + console.log( + `mock AI provider listening on http://127.0.0.1:${PORT}/v1 (model ${MODEL})` + ); +}); diff --git a/packages/ai-studio/dev-harness/mock-child-scripts.example.json b/packages/ai-studio/dev-harness/mock-child-scripts.example.json new file mode 100644 index 0000000..c03b483 --- /dev/null +++ b/packages/ai-studio/dev-harness/mock-child-scripts.example.json @@ -0,0 +1,23 @@ +{ + "designer": [ + { + "content": "Reading the project to ground the design.", + "toolCalls": [ + { + "id": "d_read", + "name": "read_game_project_json", + "arguments": { "path": "scenes" } + } + ] + }, + { + "content": "GDD_Overview written: a small harbor town." + } + ], + "developer": [ + { "content": "Implementation done: HarborTown scene and objects built." } + ], + "tester": [ + { "content": "Tests pass: Population increased after placing a house." } + ] +} diff --git a/packages/ai-studio/dev-harness/mock-script-multi.json b/packages/ai-studio/dev-harness/mock-script-multi.json new file mode 100644 index 0000000..a6c059f --- /dev/null +++ b/packages/ai-studio/dev-harness/mock-script-multi.json @@ -0,0 +1,33 @@ +[ + { + "content": "Planning three tasks.", + "toolCalls": [ + { "id": "c_plan", "name": "create_or_update_plan", "arguments": { "tasks": [ + { "id": "design", "title": "Design", "description": "Write GDD_Overview.", "status": "pending", "dependsOn": [] }, + { "id": "build", "title": "Build", "description": "Place tiles.", "status": "pending", "dependsOn": ["design"] }, + { "id": "test", "title": "Test", "description": "Verify.", "status": "pending", "dependsOn": ["build"] } + ] } } + ] + }, + { + "content": "Delegating design.", + "toolCalls": [ + { "id": "c_spawn1", "name": "spawn_agent", "arguments": { "role": "designer", "short_title": "Design", "task": "Write GDD_Overview.", "related_task_id": "design" } } + ] + }, + { + "content": "Delegating build.", + "toolCalls": [ + { "id": "c_spawn2", "name": "spawn_agent", "arguments": { "role": "developer", "short_title": "Build", "task": "Place the tiles.", "related_task_id": "build" } } + ] + }, + { + "content": "Delegating test.", + "toolCalls": [ + { "id": "c_spawn3", "name": "spawn_agent", "arguments": { "role": "tester", "short_title": "Test", "task": "Verify the build.", "related_task_id": "test" } } + ] + }, + { + "content": "All three tasks are delegated and reported." + } +] diff --git a/packages/ai-studio/dev-harness/mock-script-specialists.json b/packages/ai-studio/dev-harness/mock-script-specialists.json new file mode 100644 index 0000000..ea893eb --- /dev/null +++ b/packages/ai-studio/dev-harness/mock-script-specialists.json @@ -0,0 +1,24 @@ +[ + { + "content": "Planning design and build.", + "toolCalls": [ + { "id": "c_plan", "name": "create_or_update_plan", "arguments": { "tasks": [ + { "id": "design", "title": "Design", "description": "Write GDD_Overview.", "status": "pending", "dependsOn": [] }, + { "id": "build", "title": "Build", "description": "Build the scene.", "status": "pending", "dependsOn": ["design"] } + ] } } + ] + }, + { + "content": "Delegating design.", + "toolCalls": [ + { "id": "c_design", "name": "spawn_agent", "arguments": { "role": "designer", "short_title": "Design", "task": "Write GDD_Overview.", "related_task_id": "design" } } + ] + }, + { + "content": "Delegating build.", + "toolCalls": [ + { "id": "c_build", "name": "spawn_agent", "arguments": { "role": "developer", "short_title": "Build", "task": "Build the scene.", "related_task_id": "build" } } + ] + }, + { "content": "Both tasks delegated and reported." } +] diff --git a/packages/ai-studio/dev-harness/mock-script.example.json b/packages/ai-studio/dev-harness/mock-script.example.json new file mode 100644 index 0000000..9d2aac8 --- /dev/null +++ b/packages/ai-studio/dev-harness/mock-script.example.json @@ -0,0 +1,40 @@ +[ + { + "content": "Planning first.", + "toolCalls": [ + { + "id": "call_plan_1", + "name": "create_or_update_plan", + "arguments": { + "tasks": [ + { + "id": "design", + "title": "Design the town", + "description": "Write a one-sentence design.", + "status": "pending", + "dependsOn": [] + } + ] + } + } + ] + }, + { + "content": "Delegating the design task.", + "toolCalls": [ + { + "id": "call_spawn_1", + "name": "spawn_agent", + "arguments": { + "role": "designer", + "short_title": "Design the town", + "task": "Write GDD_Overview in one sentence.", + "related_task_id": "design" + } + } + ] + }, + { + "content": "The designer reported back; the design task is done." + } +] diff --git a/packages/unlock-shim/README.md b/packages/unlock-shim/README.md new file mode 100644 index 0000000..a3b31d3 --- /dev/null +++ b/packages/unlock-shim/README.md @@ -0,0 +1,20 @@ +# packages/unlock-shim — injected seams (Tier-1 + Tier-2 support) + +Thin, tested modules that sit between stock upstream code and the +unlocked runtime. Everything here is framework-free and covered by +`tests/` — 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` | + +## Wiring contract (what the glue patches connect) + +- Patch 07 calls `selectBackend({aiRequestId(s), customConfig})` at each + Generation.js branch point; every hosted call site is wrapped with + `guardHostedCall(fn, endpoint, pickIds)` so a `local-ai-*` id can + never leak to `api.gdevelop.io/generation`, even if a branch is missed. +- `isCustomEndpointEnabled(config)` is pure: the caller supplies the + endpoint config (read via `CustomAIClient.getCustomEndpointConfig`, + backed by `gd-custom-ai-config`). No localStorage access in this module + keeps it runnable in Node tests. diff --git a/packages/unlock-shim/generation-shim.mjs b/packages/unlock-shim/generation-shim.mjs new file mode 100644 index 0000000..7c1a1f4 --- /dev/null +++ b/packages/unlock-shim/generation-shim.mjs @@ -0,0 +1,92 @@ +// Injected Generation shim (Phase 4, issue #4): the ONLY rewiring +// between stock upstream Generation.js and packages/ai-studio/. +// +// The fork's Generation.js delta (patches/07) branches every request +// path on `local-ai-*` ids / `isCustomEndpointEnabled()`. This module +// owns the three invariants behind those branches as pure, tested +// functions so the routing can never silently degrade: +// +// 1. local-ai-* requests route to the custom (BYOK) client, always. +// 2. Hosted requests route to the hosted API, unchanged. +// 3. A local-ai-* id NEVER reaches the hosted generation API +// (no local-id leakage) — guarded, not just branched. +// +// `isCustomEndpointEnabled` here is pure (takes the endpoint config); +// reading localStorage (`gd-custom-ai-config`) stays with the caller +// (CustomAIClient.getCustomEndpointConfig), which the patch wires in. +const LOCAL_AI_REQUEST_ID_PREFIX = 'local-ai-'; + +/** True for BYOK-local request ids (`local-ai-*`). */ +export function isLocalAiRequestId(aiRequestId) { + return ( + typeof aiRequestId === 'string' && + aiRequestId.startsWith(LOCAL_AI_REQUEST_ID_PREFIX) + ); +} + +/** Split a batch into local vs hosted ids (mirrors the fork's 07 split). */ +export function partitionAiRequestIds(aiRequestIds) { + const localIds = []; + const hostedIds = []; + for (const id of aiRequestIds || []) { + if (isLocalAiRequestId(id)) localIds.push(id); + else hostedIds.push(id); + } + return { localIds, hostedIds }; +} + +/** + * Pure endpoint switch. `config` is the CustomAIConfig shape + * ({enabled, baseUrl, ...}); absent/disabled config means hosted. + */ +export function isCustomEndpointEnabled(config) { + return !!( + config && + config.enabled && + typeof config.baseUrl === 'string' && + config.baseUrl.length > 0 + ); +} + +/** + * Backend selection for one request path. + * - Any local-ai-* id -> 'custom' (config-independent: local + * requests have no hosted representation at all). + * - Custom endpoint enabled -> 'custom'. + * - Otherwise -> 'hosted'. + */ +export function selectBackend({ aiRequestId, aiRequestIds, customConfig }) { + const ids = + aiRequestIds || (aiRequestId !== undefined ? [aiRequestId] : []); + if (ids.some(isLocalAiRequestId)) return 'custom'; + if (isCustomEndpointEnabled(customConfig)) return 'custom'; + return 'hosted'; +} + +/** + * Fail-closed guard: throw before any hosted generation call that + * would carry a local-ai-* id (leakage). Callers wrap every hosted + * fetch with this; the fork's 07 branches rely on it at each site. + */ +export function assertNoLocalIdsLeak(aiRequestIds, hostedEndpoint) { + const ids = aiRequestIds || []; + const leaked = ids.filter(isLocalAiRequestId); + if (leaked.length > 0) { + throw new Error( + `[unlock-shim] refusing hosted call to ${hostedEndpoint} with ` + + `local-ai-* ids (${leaked.join(', ')}): route to the custom ` + + `client instead.` + ); + } +} + +/** + * Wrap a hosted generation function with the leak guard. `pickIds(args)` + * extracts the request id(s) from the hosted function's arguments. + */ +export function guardHostedCall(hostedFn, hostedEndpoint, pickIds) { + return function guardedHostedCall(...args) { + assertNoLocalIdsLeak(pickIds(...args), hostedEndpoint); + return hostedFn.apply(this, args); + }; +} diff --git a/patches/01-usage-predicate.patch b/patches/01-usage-predicate.patch new file mode 100644 index 0000000..7696b1a --- /dev/null +++ b/patches/01-usage-predicate.patch @@ -0,0 +1,74 @@ +--- a/newIDE/app/src/Utils/GDevelopServices/Usage.js ++++ b/newIDE/app/src/Utils/GDevelopServices/Usage.js +@@ -602,21 +602,59 @@ + return !!subscription && subscription.pricingSystemId === 'TEAM_MEMBER'; + }; + ++export const UNLOCKED_HERETEK_SUBSCRIPTION: Subscription = { ++ userId: 'local-byok-user', ++ planId: 'gdevelop_startup', ++ pricingSystemId: 'HERETEK_UNLOCKED', ++ createdAt: 0, ++ updatedAt: 0, ++}; ++ ++export const UNLOCKED_HERETEK_CAPABILITIES: Capabilities = { ++ analytics: { ++ sessions: true, ++ players: true, ++ retention: true, ++ sessionsTimeStats: true, ++ platforms: true, ++ }, ++ cloudProjects: { ++ maximumCount: 999, ++ canMaximumCountBeIncreased: true, ++ maximumGuestCollaboratorsPerProject: 99, ++ projectRestorationTimeWindowInSeconds: -1, ++ deletedProjectRetentionInDays: 365, ++ }, ++ leaderboards: { ++ maximumCountPerGame: 999, ++ canMaximumCountPerGameBeIncreased: true, ++ themeCustomizationCapabilities: 'FULL', ++ canUseCustomCss: true, ++ canDisableLoginInLeaderboard: true, ++ }, ++ multiplayer: { ++ lobbiesCount: 99, ++ maxPlayersPerLobby: 64, ++ themeCustomizationCapabilities: 'FULL', ++ }, ++ versionHistory: { ++ enabled: true, ++ retentionDays: 365, ++ }, ++ ai: { ++ availablePresets: [], ++ versionHistory: { ++ enabled: true, ++ retentionDays: 365, ++ }, ++ }, ++}; ++ + export const hasValidSubscriptionPlan = ( + subscription: ?Subscription + ): boolean => { +- const hasValidSubscription = +- !!subscription && +- !!subscription.planId && +- (!subscription.redemptionCodeValidUntil || // No redemption code +- subscription.redemptionCodeValidUntil > Date.now()); // Redemption code is still valid +- +- if (hasValidSubscription) { +- // The user has a subscription registered in the backend (classic "Registered" user). +- return true; +- } +- +- return false; ++ // In GDevelop-Heretek, all client-side and offline features are unlocked for all users. ++ return true; + }; + + type UploadType = 'build' | 'preview'; diff --git a/patches/02-auth-context-seed.patch b/patches/02-auth-context-seed.patch new file mode 100644 index 0000000..319aff3 --- /dev/null +++ b/patches/02-auth-context-seed.patch @@ -0,0 +1,57 @@ +--- a/newIDE/app/src/Profile/AuthenticatedUserContext.js ++++ b/newIDE/app/src/Profile/AuthenticatedUserContext.js +@@ -23,6 +23,8 @@ + type Subscription, + type SubscriptionPlanPricingSystem, + type UserEarningsBalance, ++ UNLOCKED_HERETEK_SUBSCRIPTION, ++ UNLOCKED_HERETEK_CAPABILITIES, + } from '../Utils/GDevelopServices/Usage'; + import { + type AssetShortHeader, +@@ -132,11 +134,20 @@ + coursePurchases: null, + bundlePurchases: null, + recommendations: null, +- subscription: null, ++ subscription: UNLOCKED_HERETEK_SUBSCRIPTION, + subscriptionPricingSystem: null, + usages: null, + userEarningsBalance: null, +- limits: null, ++ limits: { ++ quotas: {}, ++ capabilities: UNLOCKED_HERETEK_CAPABILITIES, ++ credits: { ++ userBalance: { amount: 999999 }, ++ prices: {}, ++ purchasableQuantities: [], ++ }, ++ message: undefined, ++ }, + ...authenticatedUserPropertiesLoadingState, + authenticationError: null, + onLogin: async () => {}, +@@ -190,11 +201,20 @@ + receivedAssetShortHeaders: ([]: Array), // Initialize to empty array to indicate that the loading is done. + receivedGameTemplates: ([]: Array), // Initialize to empty array to indicate that the loading is done. + receivedBundles: ([]: Array), // Initialize to empty array to indicate that the loading is done. +- subscription: null, ++ subscription: UNLOCKED_HERETEK_SUBSCRIPTION, + subscriptionPricingSystem: null, + userEarningsBalance: null, + usages: null, +- limits: null, ++ limits: { ++ quotas: {}, ++ capabilities: UNLOCKED_HERETEK_CAPABILITIES, ++ credits: { ++ userBalance: { amount: 999999 }, ++ prices: {}, ++ purchasableQuantities: [], ++ }, ++ message: undefined, ++ }, + }; + + const AuthenticatedUserContext: React.Context = React.createContext( diff --git a/patches/03-subscription-checker.patch b/patches/03-subscription-checker.patch new file mode 100644 index 0000000..6e8130e --- /dev/null +++ b/patches/03-subscription-checker.patch @@ -0,0 +1,78 @@ +--- a/newIDE/app/src/Profile/Subscription/SubscriptionChecker.js ++++ b/newIDE/app/src/Profile/Subscription/SubscriptionChecker.js +@@ -1,19 +1,11 @@ + // @flow +- + import * as React from 'react'; + import { Trans } from '@lingui/macro'; + import FlatButton from '../../UI/FlatButton'; + import Dialog from '../../UI/Dialog'; +-import AuthenticatedUserContext from '../AuthenticatedUserContext'; + import { Column } from '../../UI/Grid'; +-import { +- sendSubscriptionCheckDialogShown, +- sendSubscriptionCheckDismiss, +-} from '../../Utils/Analytics/EventSender'; ++import { sendSubscriptionCheckDismiss } from '../../Utils/Analytics/EventSender'; + import Text from '../../UI/Text'; +-import { hasValidSubscriptionPlan } from '../../Utils/GDevelopServices/Usage'; +-import { isNativeMobileApp } from '../../Utils/Platform'; +-import InAppTutorialContext from '../../InAppTutorial/InAppTutorialContext'; + import GetSubscriptionCard from './GetSubscriptionCard'; + import { ColumnStackLayout } from '../../UI/Layout'; + import { type SubscriptionPlacementId } from '../../Utils/Analytics/EventSender'; +@@ -40,18 +32,7 @@ + ...Props, + +ref?: React.RefSetter, + }> = React.forwardRef( +- ( +- { +- mode, +- id, +- title, +- onChangeSubscription, +- placementId, +- isNotShownDuringInAppTutorial, +- }, +- ref +- ) => { +- const authenticatedUser = React.useContext(AuthenticatedUserContext); ++ ({ mode, id, title, onChangeSubscription, placementId }, ref) => { + const [dialogOpen, setDialogOpen] = React.useState(false); + + const closeDialog = () => { +@@ -59,31 +40,13 @@ + setDialogOpen(false); + }; + +- const { currentlyRunningInAppTutorial } = React.useContext( +- InAppTutorialContext +- ); +- + const checkUserHasSubscription = () => { +- if ( +- hasValidSubscriptionPlan(authenticatedUser.subscription) || +- (isNotShownDuringInAppTutorial && currentlyRunningInAppTutorial) +- ) { +- setDialogOpen(false); +- return true; +- } +- +- if (isNativeMobileApp()) { +- // Would present App Store screen. +- } else { +- setDialogOpen(true); +- sendSubscriptionCheckDialogShown({ mode, id }); +- } +- +- return false; ++ setDialogOpen(false); ++ return true; + }; + + const hasUserSubscription = () => { +- return hasValidSubscriptionPlan(authenticatedUser.subscription); ++ return true; + }; + + React.useImperativeHandle(ref, () => ({ diff --git a/patches/04-loading-screen.patch b/patches/04-loading-screen.patch new file mode 100644 index 0000000..72d26ca --- /dev/null +++ b/patches/04-loading-screen.patch @@ -0,0 +1,73 @@ +--- a/newIDE/app/src/ProjectManager/LoadingScreenEditor.js ++++ b/newIDE/app/src/ProjectManager/LoadingScreenEditor.js +@@ -105,16 +105,6 @@ + } + checked={loadingScreen.isGDevelopLogoShownDuringLoadingScreen()} + onCheck={(e, checked) => { +- if ( +- !checked && +- !watermark.isGDevelopWatermarkShown() && +- subscriptionChecker.current && +- !subscriptionChecker.current.checkUserHasSubscription() +- ) { +- // If user wants to deactivate GDevelop splash screen although +- // watermark is hidden, we don't allow it if they have no subscription. +- return; +- } + loadingScreen.showGDevelopLogoDuringLoadingScreen(checked); + onUpdate(); + }} +@@ -161,53 +151,7 @@ + } + checked={watermark.isGDevelopWatermarkShown()} + onCheck={(e, checked) => { +- if ( +- !checked && +- !loadingScreen.isGDevelopLogoShownDuringLoadingScreen() && +- subscriptionChecker.current && +- !subscriptionChecker.current.checkUserHasSubscription() +- ) { +- // If user wants to deactivate watermark although GDevelop splash +- // screen is hidden, we don't allow it if they have no subscription. +- return; +- } + watermark.showGDevelopWatermark(checked); +- if (checked) { +- loadingScreen.setMinDuration( +- timeSettings.current.minDuration +- ); +- loadingScreen.setLogoAndProgressFadeInDuration( +- timeSettings.current.logoAndProgressFadeInDuration +- ); +- loadingScreen.setLogoAndProgressLogoFadeInDelay( +- timeSettings.current.logoAndProgressLogoFadeInDelay +- ); +- } else if ( +- subscriptionChecker.current && +- !subscriptionChecker.current.hasUserSubscription() +- ) { +- if ( +- loadingScreen.getMinDuration() < forcedLogo.minDuration +- ) { +- loadingScreen.setMinDuration(forcedLogo.minDuration); +- } +- if ( +- loadingScreen.getLogoAndProgressFadeInDuration() > +- forcedLogo.logoAndProgressFadeInDuration +- ) { +- loadingScreen.setLogoAndProgressFadeInDuration( +- forcedLogo.logoAndProgressFadeInDuration +- ); +- } +- if ( +- loadingScreen.getLogoAndProgressLogoFadeInDelay() > +- forcedLogo.logoAndProgressLogoFadeInDelay +- ) { +- loadingScreen.setLogoAndProgressLogoFadeInDelay( +- forcedLogo.logoAndProgressLogoFadeInDelay +- ); +- } +- } + onUpdate(); + }} + /> diff --git a/patches/05-preview-debugger.patch b/patches/05-preview-debugger.patch new file mode 100644 index 0000000..4e37c36 --- /dev/null +++ b/patches/05-preview-debugger.patch @@ -0,0 +1,46 @@ +--- a/newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/index.js ++++ b/newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/index.js +@@ -437,12 +437,6 @@ + }); + } + if (!previewOptions.isForInGameEdition) { +- if ( +- this.state.hotReloadsCount % 16 === 0 && +- this._hotReloadSubscriptionChecker +- ) { +- this._hotReloadSubscriptionChecker.checkUserHasSubscription(); +- } + this.setState(state => ({ + hotReloadsCount: state.hotReloadsCount + 1, + })); +@@ -487,9 +481,7 @@ + } + + _checkSubscriptionForNetworkPreview = (): any => { +- if (!this._networkPreviewSubscriptionChecker) return true; +- +- return this._networkPreviewSubscriptionChecker.checkUserHasSubscription(); ++ return true; + }; + + render(): any { +--- a/newIDE/app/src/MainFrame/EditorContainers/DebuggerEditorContainer.js ++++ b/newIDE/app/src/MainFrame/EditorContainers/DebuggerEditorContainer.js +@@ -133,16 +133,7 @@ + } + + _checkUserHasSubscription() { +- if ( +- this._subscriptionChecker && +- this.props.isActive && +- !this.state.subscriptionChecked +- ) { +- this._subscriptionChecker.checkUserHasSubscription(); +- this.setState({ +- subscriptionChecked: true, +- }); +- } ++ // Unlocked in GDevelop-Heretek + } + + render(): any { diff --git a/patches/06-electron-hook.patch b/patches/06-electron-hook.patch new file mode 100644 index 0000000..add4a3d --- /dev/null +++ b/patches/06-electron-hook.patch @@ -0,0 +1,19 @@ +--- a/newIDE/electron-app/app/main.js ++++ b/newIDE/electron-app/app/main.js +@@ -1,3 +1,16 @@ ++// GDevelop-Unlocked (Phase 3, issue #3): install the Tier-1 runtime hook ++// before any module destructures `electron.BrowserWindow` (main.js:6, ++// PreviewWindow.js:1-6). Re-applied by scripts/apply-patches.mjs, which ++// also syncs launcher/*.cjs next to this file. Updates drop the hook: ++// see launcher/README.md ("auto-updater pin"). ++try { ++ require('./heretek-hook.cjs').installPreloadHook(); ++} catch (error) { ++ console.error( ++ '[heretek] hook install failed (Tier-1 runtime fallback only):', ++ error ++ ); ++} + const electron = require('electron'); + const path = require('path'); + const fs = require('fs'); diff --git a/patches/07-generation-glue.patch b/patches/07-generation-glue.patch new file mode 100644 index 0000000..a2732bc --- /dev/null +++ b/patches/07-generation-glue.patch @@ -0,0 +1,713 @@ +--- a/newIDE/app/src/Utils/GDevelopServices/Generation.js ++++ b/newIDE/app/src/Utils/GDevelopServices/Generation.js +@@ -5,11 +5,26 @@ + import { type MessageByLocale } from '../i18n/MessageByLocale'; + import { getIDEVersionWithHash } from '../../Version'; + import { extractNextPageUriFromLinkHeader } from './Play'; ++import { ensureIsArray, ensureObjectHasProperty } from '../DataValidator'; + import { +- ensureIsArray, +- ensureIsObject, +- ensureObjectHasProperty, +-} from '../DataValidator'; ++ isCustomEndpointEnabled, ++ customCreateAiRequest, ++ customAddMessageToAiRequest, ++ customGetAiRequest, ++ customGetAiRequests, ++ customGetAiRequestStatuses, ++ customSuspendAiRequest, ++ customForkAiRequest, ++ customDeleteAiRequest, ++ customPatchAiRequestAttributes, ++ customRetryAiRequest, ++ customGetAiRequestSuggestions, ++ customCreateAiGeneratedEvent, ++ customCreateAssetSearch, ++ customCreateResourceSearch, ++ DEFAULT_LOCAL_AI_SETTINGS, ++ LOCAL_BYOK_USER_ID, ++} from '../../AI/CustomAIClient'; + + export type Environment = 'staging' | 'live'; + +@@ -83,15 +98,6 @@ + projectVersionIdAfterMessage?: string, + }; + +-export type AiRequestAttachment = { +- attachmentId: string, +- name: string, +- mimeType: string, +- size: number, +- // How the AI reads the file: an image or a text (otherwise, only its name). +- readableAs: 'image' | 'text' | null, +-}; +- + export type AiRequestUserMessage = { + type: 'message', + status: 'completed', +@@ -101,7 +107,6 @@ + status: 'completed', + text: string, + }>, +- attachments?: Array, + messageId?: string, + projectVersionIdBeforeMessage?: string, + }; +@@ -162,6 +167,9 @@ + forkedAfterOriginalMessageId?: string | null, + forkedAfterNewMessageId?: string | null, + parentAiRequestId?: string | null, ++ // The studio role a sub-agent was spawned with (fork-only; set only on local ++ // sub-agent requests) so its tool subset and prompt survive every turn. ++ studioRoleId?: string | null, + + error: AiRequestError | null, + contextStats?: AiRequestContextStats | null, +@@ -252,9 +260,6 @@ + // sub-events), computed by the editor, that the backend compares the + // anchor against. + placementTargetEventSource: string | null, +- // `eventScript` was built by applying `edits` to the current source of the +- // event: a compile error is always reported (never generated by the LLM). +- isEventScriptFromEdits: boolean, + }; + + export type AiGeneratedEvent = { +@@ -340,6 +345,10 @@ + outputFromMessageId?: ?string, + |} + ): Promise => { ++ if (aiRequestId.startsWith('local-ai-')) { ++ return customGetAiRequest(aiRequestId); ++ } ++ + const authorizationHeader = await getAuthorizationHeader(); + // $FlowFixMe[underconstrained-implicit-instantiation] + const response = await axios.get( +@@ -380,25 +389,42 @@ + > => { + if (aiRequestIds.length === 0) return []; + +- const authorizationHeader = await getAuthorizationHeader(); +- // $FlowFixMe[underconstrained-implicit-instantiation] +- const response = await axios.get( +- `${GDevelopGenerationApi.baseUrl}/ai-request`, +- { +- params: { +- userId, +- ids: aiRequestIds.join(','), +- include: 'status', +- }, +- headers: { +- Authorization: authorizationHeader, +- }, ++ const localIds = aiRequestIds.filter(id => id.startsWith('local-ai-')); ++ const hostedIds = aiRequestIds.filter(id => !id.startsWith('local-ai-')); ++ ++ const results = []; ++ if (localIds.length > 0) { ++ const localStatuses = await customGetAiRequestStatuses(localIds); ++ results.push(...localStatuses); ++ } ++ if (hostedIds.length > 0) { ++ const authorizationHeader = await getAuthorizationHeader(); ++ // $FlowFixMe[underconstrained-implicit-instantiation] ++ const response = await axios.get( ++ `${GDevelopGenerationApi.baseUrl}/ai-request`, ++ { ++ params: { ++ userId, ++ ids: hostedIds.join(','), ++ include: 'status', ++ }, ++ headers: { ++ Authorization: authorizationHeader, ++ }, ++ } ++ ); ++ // The response body is unvalidated: a truthy non-array (an object, a ++ // number) makes the spread throw, and a non-object element breaks the ++ // consumer that destructures `{ id, status }`. Keep only object entries. ++ if (Array.isArray(response.data)) { ++ results.push( ++ ...response.data.filter( ++ item => item && typeof item === 'object' && !Array.isArray(item) ++ ) ++ ); + } +- ); +- return ensureIsArray({ +- data: response.data, +- endpointName: '/ai-request?ids=...&include=status of Generation API', +- }); ++ } ++ return results; + }; + + /** +@@ -469,6 +495,36 @@ + all: 'any', + }; + ++/** ++ * Local BYOK chats from the client cache, shaped like the hosted ++ * /ai-request-summary contract (summaries only, no sub-agents), filtered by ++ * archived state and game when asked. Always available offline — independent ++ * of whether the custom endpoint toggle is currently on. ++ */ ++const getLocalAiRequestSummaries = ({ ++ filter, ++ gameId, ++}: {| ++ filter: AiRequestSummariesFilter, ++ gameId?: ?string, ++|}): Array => { ++ const { aiRequests } = customGetAiRequests(); ++ return aiRequests ++ .filter(aiRequest => { ++ if (aiRequest.parentAiRequestId) return false; ++ if ( ++ gameId !== undefined && ++ gameId !== null && ++ (aiRequest.gameId || null) !== gameId ++ ) ++ return false; ++ if (filter === 'active') return !aiRequest.archivedAt; ++ if (filter === 'archived') return !!aiRequest.archivedAt; ++ return true; ++ }) ++ .map(getAiRequestSummary); ++}; ++ + export const getAiRequestSummaries = async ( + getAuthorizationHeader: () => Promise, + { +@@ -489,33 +545,54 @@ + aiRequestSummaries: Array, + nextPageUri: ?string, + }> => { ++ // Always list local BYOK chats (from the client cache) so toggling the ++ // custom endpoint off or logging out does not hide them. The hosted page is ++ // fetched only when a real profile userId is given — LOCAL_BYOK is offline. ++ const localSummaries = getLocalAiRequestSummaries({ filter, gameId }); ++ if (userId === LOCAL_BYOK_USER_ID) { ++ return { aiRequestSummaries: localSummaries, nextPageUri: null }; ++ } ++ + const authorizationHeader = await getAuthorizationHeader(); + const uri = forceUri || '/ai-request-summary'; + +- // $FlowFixMe[incompatible-type] +- const response = await apiClient.get(uri, { +- headers: { +- Authorization: authorizationHeader, +- }, +- params: forceUri +- ? { userId } +- : { +- userId, +- perPage: 10, +- archived: archivedParameterByFilter[filter], +- gameId: gameId || undefined, +- }, +- }); +- const nextPageUri = response.headers.link +- ? extractNextPageUriFromLinkHeader(response.headers.link) +- : null; +- return { +- aiRequestSummaries: ensureIsArray({ ++ try { ++ // $FlowFixMe[incompatible-type] ++ const response = await apiClient.get(uri, { ++ headers: { ++ Authorization: authorizationHeader, ++ }, ++ params: forceUri ++ ? { userId } ++ : { ++ userId, ++ perPage: 10, ++ archived: archivedParameterByFilter[filter], ++ gameId: gameId || undefined, ++ }, ++ }); ++ const nextPageUri = response.headers.link ++ ? extractNextPageUriFromLinkHeader(response.headers.link) ++ : null; ++ const hostedSummaries = ensureIsArray({ + data: response.data, + endpointName: '/ai-request-summary of Generation API', +- }), +- nextPageUri, +- }; ++ }); ++ // Merge (statuses already do this for local-ai-* ids): local first, then ++ // the hosted page. The UI re-sorts by createdAt before display. ++ return { ++ aiRequestSummaries: [...localSummaries, ...hostedSummaries], ++ nextPageUri, ++ }; ++ } catch (error) { ++ // Offline / hosted outage: still show whatever local chats exist rather ++ // than blanking the history. Re-throw only when there is nothing to fall ++ // back to, so the context can surface its retry UI. ++ if (localSummaries.length > 0 || isCustomEndpointEnabled()) { ++ return { aiRequestSummaries: localSummaries, nextPageUri: null }; ++ } ++ throw error; ++ } + }; + + export const createAiRequest = async ( +@@ -523,7 +600,6 @@ + { + userId, + userRequest, +- attachmentIds, + gameProjectJson, + gameProjectJsonUserRelativeKey, + projectSpecificExtensionsSummaryJson, +@@ -539,7 +615,6 @@ + }: {| + userId: string, + userRequest: string, +- attachmentIds: Array, + gameProjectJson: string | null, + gameProjectJsonUserRelativeKey: string | null, + projectSpecificExtensionsSummaryJson: string | null, +@@ -559,13 +634,25 @@ + toolsVersion: string, + |} + ): Promise => { ++ // Offline BYOK (local-byok-user) must create locally even if the endpoint ++ // toggle was flipped off after the session started. ++ if (isCustomEndpointEnabled() || (userId && userId.startsWith('local-'))) { ++ return customCreateAiRequest({ ++ userRequest, ++ gameProjectJson, ++ projectSpecificExtensionsSummaryJson, ++ mode, ++ aiConfiguration, ++ gameId, ++ }); ++ } ++ + const authorizationHeader = await getAuthorizationHeader(); + const response = await apiClient.post( + '/ai-request', + { + gdevelopVersionWithHash: getIDEVersionWithHash(), + userRequest, +- attachmentIds, + gameProjectJson, + gameProjectJsonUserRelativeKey, + projectSpecificExtensionsSummaryJson, +@@ -603,7 +690,6 @@ + aiRequestId, + functionCallOutputs, + userMessage, +- attachmentIds, + gameId, + projectVersionIdBeforeMessage, + payWithCredits, +@@ -618,7 +704,6 @@ + userId: string, + aiRequestId: string, + userMessage: string, +- attachmentIds?: Array, + gameId?: string, + projectVersionIdBeforeMessage?: string | null, + functionCallOutputs: Array, +@@ -632,6 +717,17 @@ + toolsVersion?: string, + |} + ): Promise => { ++ if (aiRequestId.startsWith('local-ai-')) { ++ return customAddMessageToAiRequest({ ++ aiRequestId, ++ userMessage, ++ functionCallOutputs, ++ gameProjectJson, ++ projectSpecificExtensionsSummaryJson, ++ mode, ++ }); ++ } ++ + const authorizationHeader = await getAuthorizationHeader(); + const response = await apiClient.post( + `/ai-request/${aiRequestId}/action/add-message`, +@@ -639,7 +735,6 @@ + gdevelopVersionWithHash: getIDEVersionWithHash(), + functionCallOutputs, + userMessage, +- attachmentIds, + gameId, + projectVersionIdBeforeMessage, + payWithCredits: !!payWithCredits, +@@ -676,6 +771,10 @@ + getAuthorizationHeader: () => Promise, + { userId, aiRequestId }: {| userId: string, aiRequestId: string |} + ): Promise => { ++ if (aiRequestId.startsWith('local-ai-')) { ++ return customRetryAiRequest(aiRequestId); ++ } ++ + const authorizationHeader = await getAuthorizationHeader(); + const response = await apiClient.post( + `/ai-request/${aiRequestId}/action/retry`, +@@ -693,6 +792,10 @@ + getAuthorizationHeader: () => Promise, + { userId, aiRequestId }: {| userId: string, aiRequestId: string |} + ): Promise => { ++ if (aiRequestId.startsWith('local-ai-')) { ++ return customSuspendAiRequest(aiRequestId); ++ } ++ + const authorizationHeader = await getAuthorizationHeader(); + const response = await apiClient.post( + `/ai-request/${aiRequestId}/action/suspend`, +@@ -725,6 +828,10 @@ + archived?: boolean, + |} + ): Promise => { ++ if (aiRequestId.startsWith('local-ai-')) { ++ return customPatchAiRequestAttributes(aiRequestId, { title, archived }); ++ } ++ + const authorizationHeader = await getAuthorizationHeader(); + const attributes: { title?: string | null, archived?: boolean } = {}; + if (title !== undefined) attributes.title = title; +@@ -745,6 +852,11 @@ + getAuthorizationHeader: () => Promise, + { userId, aiRequestId }: {| userId: string, aiRequestId: string |} + ): Promise => { ++ if (aiRequestId.startsWith('local-ai-')) { ++ customDeleteAiRequest(aiRequestId); ++ return; ++ } ++ + const authorizationHeader = await getAuthorizationHeader(); + await apiClient.delete(`/ai-request/${aiRequestId}`, { + params: { userId }, +@@ -768,6 +880,9 @@ + projectVersionIdAfterMessage?: ?string, + |} + ): Promise => { ++ if (aiRequestId.startsWith('local-ai-')) { ++ return; ++ } + const authorizationHeader = await getAuthorizationHeader(); + await apiClient.patch( + `/ai-request/${aiRequestId}/message/${aiRequestMessageId}`, +@@ -804,6 +919,11 @@ + freeFormDetails?: string, + |} + ): Promise => { ++ // Local BYOK chats have no hosted feedback store — never POST local-ai-* ++ // ids to the Generation API (same short-circuit as retry/suspend/delete). ++ if (aiRequestId.startsWith('local-ai-')) { ++ return customGetAiRequest(aiRequestId); ++ } + const authorizationHeader = await getAuthorizationHeader(); + const response = await apiClient.post( + `/ai-request/${aiRequestId}/action/set-feedback`, +@@ -850,6 +970,10 @@ + projectSpecificExtensionsSummaryJsonUserRelativeKey: string | null, + |} + ): Promise => { ++ if (aiRequestId.startsWith('local-ai-')) { ++ return customGetAiRequestSuggestions(aiRequestId); ++ } ++ + const authorizationHeader = await getAuthorizationHeader(); + const response = await apiClient.post( + `/ai-request/${aiRequestId}/action/get-suggestions`, +@@ -919,7 +1043,7 @@ + functionName: string | null, + sceneName: string, + eventsDescription: string | null, +- eventBatches: Array | null, ++ eventBatches: ?EventsBatchToGenerate, + extensionNamesList: string, + objectsList: string, + existingEventsJson: string | null, +@@ -929,6 +1053,24 @@ + estimatedComplexity: number | null, + |} + ): Promise => { ++ // Local chats (local-ai-*) and offline BYOK users must not hit the hosted ++ // event-generation API when the endpoint toggle is off (mixed history). ++ if ( ++ isCustomEndpointEnabled() || ++ (userId && userId.startsWith('local-')) || ++ (relatedAiRequestId && relatedAiRequestId.startsWith('local-ai-')) ++ ) { ++ return customCreateAiGeneratedEvent({ ++ sceneName, ++ eventsDescription, ++ eventBatches, ++ extensionNamesList, ++ objectsList, ++ existingEventsAsText: existingEventsJson || undefined, ++ aiRequestId: relatedAiRequestId || null, ++ }); ++ } ++ + const authorizationHeader = await getAuthorizationHeader(); + const response = await apiClient.post( + `/ai-generated-event`, +@@ -998,6 +1140,28 @@ + aiGeneratedEventId: string, + |} + ): Promise => { ++ if (aiGeneratedEventId.startsWith('local-evt-')) { ++ return { ++ id: aiGeneratedEventId, ++ createdAt: new Date().toISOString(), ++ updatedAt: new Date().toISOString(), ++ userId: LOCAL_BYOK_USER_ID, ++ status: 'ready', ++ partialGameProjectJson: '', ++ eventsDescription: null, ++ eventBatches: null, ++ extensionNamesList: '', ++ objectsList: '', ++ existingEventsAsText: '', ++ existingEventsJson: null, ++ existingEventsJsonUserRelativeKey: null, ++ resultMessage: null, ++ changes: [], ++ error: null, ++ stats: null, ++ }; ++ } ++ + const authorizationHeader = await getAuthorizationHeader(); + const response = await apiClient.get( + `/ai-generated-event/${aiGeneratedEventId}`, +@@ -1022,8 +1186,8 @@ + { + userId, + searchTerms, +- description, + objectType, ++ description, + twoDimensionalViewKind, + exactOrPartialAssetId, + relatedAiRequestId, +@@ -1032,23 +1196,30 @@ + }: {| + userId: string, + searchTerms: string, +- description: string, +- objectType: string | null, +- twoDimensionalViewKind: string, +- exactOrPartialAssetId?: string | null, +- relatedAiRequestId?: string | null, +- lastUserMessage?: string | null, ++ objectType?: ?string, ++ description?: ?string, ++ twoDimensionalViewKind?: ?string, ++ exactOrPartialAssetId?: ?string, ++ relatedAiRequestId?: ?string, ++ lastUserMessage?: ?string, + lastAssistantMessages?: string[], + |} + ): Promise => { ++ if ( ++ isCustomEndpointEnabled() || ++ (userId && userId.startsWith('local-')) || ++ (relatedAiRequestId && relatedAiRequestId.startsWith('local-ai-')) ++ ) { ++ return customCreateAssetSearch({ searchTerms, objectType }); ++ } ++ + const authorizationHeader = await getAuthorizationHeader(); + const response = await apiClient.post( + `/asset-search`, + { +- gdevelopVersionWithHash: getIDEVersionWithHash(), + searchTerms, +- description, + objectType, ++ description, + twoDimensionalViewKind, + exactOrPartialAssetId, + relatedAiRequestId, +@@ -1083,11 +1254,14 @@ + resourceKind: string, + |} + ): Promise => { ++ if (isCustomEndpointEnabled() || (userId && userId.startsWith('local-'))) { ++ return customCreateResourceSearch({ searchTerms, resourceKind }); ++ } ++ + const authorizationHeader = await getAuthorizationHeader(); + const response = await apiClient.post( + `/resource-search`, + { +- gdevelopVersionWithHash: getIDEVersionWithHash(), + searchTerms, + resourceKind, + }, +@@ -1130,11 +1304,20 @@ + eventsJsonHash: string | null, + |} + ): Promise => { ++ if (isCustomEndpointEnabled() || (userId && userId.startsWith('local-'))) { ++ return { ++ gameProjectJsonSignedUrl: undefined, ++ gameProjectJsonUserRelativeKey: undefined, ++ projectSpecificExtensionsSummaryJsonSignedUrl: undefined, ++ projectSpecificExtensionsSummaryJsonUserRelativeKey: undefined, ++ eventsJsonSignedUrl: undefined, ++ eventsJsonUserRelativeKey: undefined, ++ }; ++ } + const authorizationHeader = await getAuthorizationHeader(); + const response = await apiClient.post( + `/ai-user-content/action/create-presigned-urls`, + { +- gdevelopVersionWithHash: getIDEVersionWithHash(), + gameProjectJsonHash, + projectSpecificExtensionsSummaryJsonHash, + eventsJsonHash, +@@ -1148,88 +1331,14 @@ + }, + } + ); +- return ensureIsObject({ ++ return ensureObjectHasProperty({ + data: response.data, ++ propertyName: 'gameProjectJsonSignedUrl', + endpointName: + '/ai-user-content/action/create-presigned-urls of Generation API', + }); + }; + +-export type AiAttachmentUpload = {| +- attachmentId: string, +- uploadUrl: string, +- visionCopyUploadUrl: string | null, +-|}; +- +-export const createAiAttachmentUploads = async ( +- getAuthorizationHeader: () => Promise, +- { +- userId, +- files, +- }: {| +- userId: string, +- files: Array<{| +- name: string, +- mimeType: string, +- size: number, +- visionCopy: {| mimeType: string, size: number |} | null, +- |}>, +- |} +-): Promise> => { +- const authorizationHeader = await getAuthorizationHeader(); +- const response = await apiClient.post( +- `/ai-user-content/action/create-attachment-uploads`, +- { files }, +- { +- params: { userId }, +- headers: { Authorization: authorizationHeader }, +- } +- ); +- return ensureIsArray({ +- data: response.data.attachmentUploads, +- endpointName: +- '/ai-user-content/action/create-attachment-uploads of Generation API', +- }); +-}; +- +-export type AiAttachmentDownload = +- | {| +- attachmentId: string, +- url: string, +- // A downscaled copy for an image, if any (otherwise the file itself). +- previewUrl: string, +- name: string, +- mimeType: string, +- size: number, +- |} +- | {| attachmentId: string, error: 'not-found' |}; +- +-export const createAiAttachmentDownloads = async ( +- getAuthorizationHeader: () => Promise, +- { +- userId, +- attachmentIds, +- }: {| +- userId: string, +- attachmentIds: Array, +- |} +-): Promise> => { +- const authorizationHeader = await getAuthorizationHeader(); +- const response = await apiClient.post( +- `/ai-user-content/action/create-attachment-downloads`, +- { attachmentIds }, +- { +- params: { userId }, +- headers: { Authorization: authorizationHeader }, +- } +- ); +- return ensureIsArray({ +- data: response.data.attachmentDownloads, +- endpointName: +- '/ai-user-content/action/create-attachment-downloads of Generation API', +- }); +-}; +- + export type AiConfigurationPreset = {| + mode: 'chat' | 'agent' | 'orchestrator', + id: string, +@@ -1258,6 +1367,10 @@ + upToMessageId?: string, + |} + ): Promise => { ++ if (aiRequestId.startsWith('local-ai-')) { ++ return customForkAiRequest(aiRequestId, upToMessageId); ++ } ++ + const authorizationHeader = await getAuthorizationHeader(); + const response = await apiClient.post( + `/ai-request/${aiRequestId}/action/fork`, +@@ -1285,13 +1398,25 @@ + }: {| + environment: Environment, + |}): Promise => { +- // $FlowFixMe[underconstrained-implicit-instantiation] +- const response = await axios.get( +- `${GDevelopAiCdn.baseUrl[environment]}/ai-settings-v2.json` +- ); +- return ensureObjectHasProperty({ +- data: response.data, +- propertyName: 'aiRequest', +- endpointName: '/ai-settings-v2.json of Generation API', +- }); ++ // Local/BYOK sessions never need the hosted preset catalog: plan gating is ++ // unused (all presets available) and DEFAULT_LOCAL_AI_SETTINGS already has ++ // the orchestrator/chat/agent defaults. Skip the CDN so offline custom ++ // endpoint users do not wait on axios timeouts every editor open. ++ if (isCustomEndpointEnabled()) { ++ return DEFAULT_LOCAL_AI_SETTINGS; ++ } ++ try { ++ // $FlowFixMe[underconstrained-implicit-instantiation] ++ const response = await axios.get( ++ `${GDevelopAiCdn.baseUrl[environment]}/ai-settings-v2.json`, ++ { timeout: 5000 } ++ ); ++ return ensureObjectHasProperty({ ++ data: response.data, ++ propertyName: 'aiRequest', ++ endpointName: '/ai-settings-v2.json of Generation API', ++ }); ++ } catch (err) { ++ return DEFAULT_LOCAL_AI_SETTINGS; ++ } + }; diff --git a/patches/08-preferences-ai.patch b/patches/08-preferences-ai.patch new file mode 100644 index 0000000..a1702b0 --- /dev/null +++ b/patches/08-preferences-ai.patch @@ -0,0 +1,760 @@ +--- a/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js ++++ b/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js +@@ -21,7 +21,7 @@ + } from './PreferencesContext'; + import Text from '../../UI/Text'; + import EmptyMessage from '../../UI/EmptyMessage'; +-import { ColumnStackLayout } from '../../UI/Layout'; ++import { ColumnStackLayout, LineStackLayout } from '../../UI/Layout'; + import { Tabs } from '../../UI/Tabs'; + import SettingsRow, { useSettingsRowControlIds } from '../../UI/SettingsRow'; + import VerticalTabButton from '../../UI/VerticalTabButton'; +@@ -41,6 +41,9 @@ + import { type MessageDescriptor } from '../../Utils/i18n/MessageDescriptor.flow'; + import { useResponsiveWindowSize } from '../../UI/Responsive/ResponsiveWindowMeasurer'; + import { adaptAcceleratorString } from '../../UI/AcceleratorString'; ++import RaisedButton from '../../UI/RaisedButton'; ++import TextField from '../../UI/TextField'; ++import { testConnection, MAX_TIMEOUT_MS } from '../../AI/CustomAIClient'; + import { + getElectronAccelerator, + getShortcutDisplayName, +@@ -91,7 +94,7 @@ + | 'extensions' + | 'developer'; + +-export type PreferencesTabName = 'preferences' | 'shortcuts'; ++export type PreferencesTabName = 'preferences' | 'ai' | 'shortcuts'; + + type GetIconFunction = ({ + color: string, +@@ -339,6 +342,7 @@ + type Props = {| + i18n: I18n, + onClose: (options: {| languageDidChange: boolean |}) => void, ++ onOpenQuickCustomizationDialog: () => void, + /** The tab displayed when the dialog opens. Defaults to the preferences. */ + initialTab?: PreferencesTabName, + /** The preferences section displayed when the dialog opens. Defaults to the general one. */ +@@ -378,6 +382,7 @@ + const PreferencesDialog = ({ + i18n, + onClose, ++ onOpenQuickCustomizationDialog, + initialTab, + initialSection, + isDesktop = !!electron, +@@ -444,8 +449,26 @@ + setDisableNpmScriptConfirmation, + setUseBackgroundSerializerForSaving, + setShowJsTypeError, ++ setAiCustomEndpointEnabled, ++ setAiCustomBaseUrl, ++ setAiCustomApiKey, ++ setAiCustomModel, ++ setAiCustomFallbackModel, ++ setAiCustomTemperature, ++ setAiCustomTimeoutMs, ++ setAiCustomStreaming, + } = React.useContext(PreferencesContext); + ++ const [ ++ isTestingAiConnection, ++ setIsTestingAiConnection, ++ ] = React.useState(false); ++ const [aiTestResult, setAiTestResult] = React.useState, ++ |}>(null); ++ + const initialUse3DEditor = React.useRef(values.use3DEditor); + + const isSearching = searchText.trim() !== ''; +@@ -554,7 +577,7 @@ + if (sectionNameAtTop && sectionNameAtTop !== currentSection) { + setCurrentSection((sectionNameAtTop: any)); + } +- } else { ++ } else if (currentTab === 'shortcuts') { + const areaNameAtTop = getEntryKeyAtTop( + shortcutAreas.map(area => area.name), + getShortcutAreaElementId +@@ -563,6 +586,7 @@ + setCurrentShortcutArea((areaNameAtTop: any)); + } + } ++ // The 'ai' tab has a single section with no scroll-spy: nothing to update. + }; + + const hasCustomizedShortcuts = Object.keys(values.userShortcutMap).some( +@@ -1237,6 +1261,16 @@ + setUseGDJSDevelopmentWatcher + ), + }, ++ { ++ id: 'quick-customization', ++ label: i18n._(t`Quick customization`), ++ renderControl: () => ( ++ Open} ++ /> ++ ), ++ }, + ], + }; + case 'contributor': +@@ -1323,6 +1357,152 @@ + * sections list on the left (not displayed on mobile) scrolls to a section. + */ + const renderCurrentTabContent = () => { ++ if (currentTab === 'ai') { ++ return ( ++ ++ ++ Custom AI Provider (BYOK) ++ ++ ++ ++ Connect GDevelop to a local AI server (such as Ollama, llama.cpp, ++ LM Studio, LocalAI) or any OpenAI-compatible API endpoint. ++ ++ ++ Enable Custom / Local AI Endpoint (BYOK)} ++ /> ++ ++ ++ Local server presets: ++ ++ {[ ++ { label: 'Ollama', baseUrl: 'http://localhost:11434/v1' }, ++ { label: 'LM Studio', baseUrl: 'http://localhost:1234/v1' }, ++ { label: 'llama.cpp', baseUrl: 'http://localhost:8080/v1' }, ++ ].map(preset => ( ++ setAiCustomBaseUrl(preset.baseUrl)} ++ /> ++ ))} ++ ++ Base URL} ++ helperMarkdownText={i18n._( ++ t`Examples: \`http://localhost:11434/v1\` (Ollama), \`http://localhost:8080/v1\` (llama.cpp), \`http://localhost:1234/v1\` (LM Studio), \`https://api.openai.com/v1\`, \`https://openrouter.ai/api/v1\`` ++ )} ++ fullWidth ++ value={values.aiCustomBaseUrl} ++ onChange={(e, value) => setAiCustomBaseUrl(value)} ++ /> ++ API Key} ++ helperMarkdownText={i18n._( ++ t`Bearer token for authentication. Leave empty for local servers (Ollama / llama.cpp).` ++ )} ++ fullWidth ++ value={values.aiCustomApiKey} ++ onChange={(e, value) => setAiCustomApiKey(value)} ++ /> ++ Model Identifier} ++ helperMarkdownText={i18n._( ++ t`Examples: \`qwen2.5-coder\`, \`gpt-4o\`, \`llama3.2\`, \`deepseek-chat\`, \`claude-3-5-sonnet\`, \`openrouter/poolside/laguna-s-2.1:free\`` ++ )} ++ fullWidth ++ value={values.aiCustomModel} ++ onChange={(e, value) => setAiCustomModel(value)} ++ /> ++ Fallback model (optional)} ++ helperMarkdownText={i18n._( ++ t`Used once if the primary model keeps returning a transient provider error (502/503/429). Leave empty to disable.` ++ )} ++ fullWidth ++ value={values.aiCustomFallbackModel} ++ onChange={(e, value) => setAiCustomFallbackModel(value)} ++ /> ++ Temperature (0.0 to 1.0)} ++ value={values.aiCustomTemperature} ++ onChange={(e, value) => { ++ const parsed = parseFloat(value); ++ if (Number.isNaN(parsed)) { ++ setAiCustomTemperature(0.7); ++ } else { ++ setAiCustomTemperature(Math.max(0.0, Math.min(1.0, parsed))); ++ } ++ }} ++ /> ++ ++ Stream responses (experimental — falls back automatically if the ++ endpoint does not support it) ++ ++ } ++ /> ++ Request timeout in milliseconds} ++ helperMarkdownText={i18n._( ++ t`Time before a request to the AI endpoint is aborted. Increase this for slow local models on large contexts. Default: 120000.` ++ )} ++ fullWidth ++ value={values.aiCustomTimeoutMs} ++ onChange={(e, value) => { ++ const parsed = parseInt(value, 10); ++ if (Number.isNaN(parsed) || parsed <= 0) { ++ setAiCustomTimeoutMs(120000); ++ } else { ++ setAiCustomTimeoutMs(Math.min(parsed, MAX_TIMEOUT_MS)); ++ } ++ }} ++ /> ++ ++ Test Connection} ++ primary ++ disabled={isTestingAiConnection} ++ onClick={async () => { ++ setIsTestingAiConnection(true); ++ setAiTestResult(null); ++ try { ++ const res = await testConnection({ ++ enabled: true, ++ baseUrl: values.aiCustomBaseUrl, ++ apiKey: values.aiCustomApiKey, ++ model: values.aiCustomModel, ++ temperature: values.aiCustomTemperature, ++ }); ++ setAiTestResult(res); ++ } catch (err) { ++ setAiTestResult({ ++ success: false, ++ message: err.message || 'Connection test failed', ++ }); ++ } finally { ++ setIsTestingAiConnection(false); ++ } ++ }} ++ /> ++ ++ {aiTestResult && ( ++ ++ {aiTestResult.message} ++ ++ )} ++ ++ ); ++ } + if (currentTab === 'shortcuts') { + return ( + +@@ -1424,7 +1604,9 @@ + setCurrentTab(tabName); + // Start from the top of the new tab. + if (tabName === 'preferences') setCurrentSection(visibleSections[0].name); +- else setCurrentShortcutArea(shortcutAreas[0].name); ++ else if (tabName === 'shortcuts') ++ setCurrentShortcutArea(shortcutAreas[0].name); ++ // The 'ai' tab has no sub-navigation (no sections list on the left). + pendingScrollElementIdRef.current = ''; + }; + +@@ -1442,6 +1624,7 @@ + + // The entries of the sections list, on the left of the content: the + // preferences sections or the shortcut areas, depending on the current tab. ++ // The 'ai' tab is a single page with no sub-sections, so it has no entries. + const sectionListEntries = + currentTab === 'preferences' + ? visibleSections.map(section => ({ +@@ -1451,13 +1634,15 @@ + isActive: !isSearching && highlightedSectionName === section.name, + onSelect: () => onSelectSection(section.name), + })) +- : shortcutAreas.map(area => ({ ++ : currentTab === 'shortcuts' ++ ? shortcutAreas.map(area => ({ + key: area.name, + label: i18n._(commandAreas[area.name]), + getIcon: area.getIcon, + isActive: !isSearching && currentShortcutArea === area.name, + onSelect: () => onSelectShortcutArea(area.name), +- })); ++ })) ++ : []; + + return ( + Preferences }, ++ { value: 'ai', label: AI Settings }, + { value: 'shortcuts', label: Keyboard Shortcuts }, + ]} + /> +@@ -1527,7 +1713,7 @@ + } + > +
+- {!isMobile && ( ++ {!isMobile && sectionListEntries.length > 0 && ( + <> +
+ +--- a/newIDE/app/src/MainFrame/Preferences/PreferencesContext.js ++++ b/newIDE/app/src/MainFrame/Preferences/PreferencesContext.js +@@ -218,7 +218,6 @@ + hasProjectOpened: boolean, + userShortcutMap: ShortcutMap, + newObjectDialogDefaultTab: 'asset-store' | 'new-object', +- newEffectDialogDefaultTab: 'asset-store' | 'from-scratch', + shareDialogDefaultTab: 'invite' | 'publish', + isMenuBarHiddenInPreview: boolean, + isAlwaysOnTopInPreview: boolean, +@@ -259,6 +258,14 @@ + disableNpmScriptConfirmation: boolean, + showJsTypeError: boolean, + canonicalEventSerialization: boolean, ++ aiCustomEndpointEnabled: boolean, ++ aiCustomBaseUrl: string, ++ aiCustomApiKey: string, ++ aiCustomModel: string, ++ aiCustomFallbackModel: string, ++ aiCustomTemperature: number, ++ aiCustomTimeoutMs: number, ++ aiCustomStreaming: boolean, + |}; + + /** +@@ -323,8 +330,6 @@ + setShortcutForCommand: (commandName: CommandName, shortcut: string) => void, + getNewObjectDialogDefaultTab: () => 'asset-store' | 'new-object', + setNewObjectDialogDefaultTab: ('asset-store' | 'new-object') => void, +- getNewEffectDialogDefaultTab: () => 'asset-store' | 'from-scratch', +- setNewEffectDialogDefaultTab: ('asset-store' | 'from-scratch') => void, + getShareDialogDefaultTab: () => 'invite' | 'publish', + setShareDialogDefaultTab: ('invite' | 'publish') => void, + getIsMenuBarHiddenInPreview: () => boolean, +@@ -394,6 +399,14 @@ + setUseBackgroundSerializerForSaving: (enabled: boolean) => void, + setShowJsTypeError: (enabled: boolean) => void, + setCanonicalEventSerialization: (enabled: boolean) => void, ++ setAiCustomEndpointEnabled: (enabled: boolean) => void, ++ setAiCustomBaseUrl: (baseUrl: string) => void, ++ setAiCustomApiKey: (apiKey: string) => void, ++ setAiCustomModel: (model: string) => void, ++ setAiCustomFallbackModel: (model: string) => void, ++ setAiCustomTemperature: (temperature: number) => void, ++ setAiCustomTimeoutMs: (timeoutMs: number) => void, ++ setAiCustomStreaming: (streaming: boolean) => void, + |}; + + export const initialPreferences = { +@@ -428,8 +441,6 @@ + newObjectDialogDefaultTab: ((electron + ? 'new-object' + : 'asset-store'): string), +- // Show the effects of the store first, as they are not otherwise visible. +- newEffectDialogDefaultTab: 'asset-store', + shareDialogDefaultTab: 'publish', + isMenuBarHiddenInPreview: true, + isAlwaysOnTopInPreview: false, +@@ -465,6 +476,14 @@ + disableNpmScriptConfirmation: false, + showJsTypeError: false, + canonicalEventSerialization: false, ++ aiCustomEndpointEnabled: false, ++ aiCustomBaseUrl: 'http://localhost:11434/v1', ++ aiCustomApiKey: '', ++ aiCustomModel: 'qwen2.5-coder', ++ aiCustomFallbackModel: '', ++ aiCustomTemperature: 0.7, ++ aiCustomTimeoutMs: 120000, ++ aiCustomStreaming: false, + }, + setMultipleValues: () => {}, + setLanguage: () => {}, +@@ -507,8 +526,6 @@ + setShortcutForCommand: (commandName: CommandName, shortcut: string) => {}, + getNewObjectDialogDefaultTab: (): string => 'asset-store', + setNewObjectDialogDefaultTab: () => {}, +- getNewEffectDialogDefaultTab: (): string => 'asset-store', +- setNewEffectDialogDefaultTab: () => {}, + getShareDialogDefaultTab: (): string => 'invite', + setShareDialogDefaultTab: () => {}, + getIsMenuBarHiddenInPreview: (): boolean => true, +@@ -563,6 +580,14 @@ + setUseBackgroundSerializerForSaving: (enabled: boolean) => {}, + setShowJsTypeError: (enabled: boolean) => {}, + setCanonicalEventSerialization: (enabled: boolean) => {}, ++ setAiCustomEndpointEnabled: (enabled: boolean) => {}, ++ setAiCustomBaseUrl: (baseUrl: string) => {}, ++ setAiCustomApiKey: (apiKey: string) => {}, ++ setAiCustomModel: (model: string) => {}, ++ setAiCustomFallbackModel: (model: string) => {}, ++ setAiCustomTemperature: (temperature: number) => {}, ++ setAiCustomTimeoutMs: (timeoutMs: number) => {}, ++ setAiCustomStreaming: (streaming: boolean) => {}, + }; + + const PreferencesContext: React.Context = React.createContext( +--- a/newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js ++++ b/newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js +@@ -34,6 +34,11 @@ + CHECK_APP_UPDATES_TIMEOUT, + PERIODIC_APP_UPDATES_TIMEOUT, + } from '../../Utils/GlobalFetchTimeouts'; ++import { setCustomEndpointConfig } from '../../AI/CustomAIClient'; ++import { ++ getPersistablePreferences, ++ mergeStoredPreferencesWithDefaults, ++} from './PreferencesStorage'; + const electron = optionalRequire('electron'); + const ipcRenderer = electron ? electron.ipcRenderer : null; + +@@ -51,35 +56,13 @@ + const persistedState = localStorage.getItem(localStorageItem); + if (!persistedState) return null; + +- const values = JSON.parse(persistedState); +- +- // "Migrate" non existing properties to their default values +- // (useful when upgrading the preferences to a new version where +- // a new preference was added). +- for (const key in initialPreferences.values) { +- if ( +- initialPreferences.values.hasOwnProperty(key) && +- typeof values[key] === 'undefined' +- ) { +- // $FlowFixMe[invalid-computed-prop] +- values[key] = initialPreferences.values[key]; +- } +- } +- +- // Migrate renamed themes. +- if (values.themeName === 'GDevelop default') { +- values.themeName = 'GDevelop default Light'; +- } else if (values.themeName === 'Dark') { +- values.themeName = 'Blue Dark'; +- } +- +- if (typeof values.showDeprecatedInstructionWarning === 'boolean') { +- values.showDeprecatedInstructionWarning = values.showDeprecatedInstructionWarning +- ? 'icon' +- : 'no'; +- } +- +- return values; ++ // Backfill missing keys with defaults and apply the stored-value ++ // migrations. The merge lives in PreferencesStorage so its contract ++ // (backfill undefined only; never clobber a present value) is unit-tested. ++ return mergeStoredPreferencesWithDefaults( ++ JSON.parse(persistedState), ++ initialPreferences.values ++ ); + } catch (e) { + return null; + } +@@ -118,7 +101,6 @@ + lastLaunchedVersion: void, + newFeaturesAcknowledgements: {}, + newObjectDialogDefaultTab: any, +- newEffectDialogDefaultTab: string, + newProjectsDefaultFolder: any, + newProjectsDefaultStorageProviderName: string, + openDiagnosticReportAutomatically: boolean, +@@ -277,14 +259,6 @@ + this + ): any), + // $FlowFixMe[method-unbinding] +- getNewEffectDialogDefaultTab: (this._getNewEffectDialogDefaultTab.bind( +- this +- ): any), +- // $FlowFixMe[method-unbinding] +- setNewEffectDialogDefaultTab: (this._setNewEffectDialogDefaultTab.bind( +- this +- ): any), +- // $FlowFixMe[method-unbinding] + getShareDialogDefaultTab: (this._getShareDialogDefaultTab.bind(this): any), + // $FlowFixMe[method-unbinding] + setShareDialogDefaultTab: (this._setShareDialogDefaultTab.bind(this): any), +@@ -436,9 +410,39 @@ + setCanonicalEventSerialization: (this._setCanonicalEventSerialization.bind( + this + ): any), ++ // $FlowFixMe[method-unbinding] ++ setAiCustomEndpointEnabled: (this._setAiCustomEndpointEnabled.bind( ++ this ++ ): any), ++ // $FlowFixMe[method-unbinding] ++ setAiCustomBaseUrl: (this._setAiCustomBaseUrl.bind(this): any), ++ // $FlowFixMe[method-unbinding] ++ setAiCustomApiKey: (this._setAiCustomApiKey.bind(this): any), ++ // $FlowFixMe[method-unbinding] ++ setAiCustomModel: (this._setAiCustomModel.bind(this): any), ++ // $FlowFixMe[method-unbinding] ++ setAiCustomFallbackModel: (this._setAiCustomFallbackModel.bind(this): any), ++ // $FlowFixMe[method-unbinding] ++ setAiCustomTemperature: (this._setAiCustomTemperature.bind(this): any), ++ // $FlowFixMe[method-unbinding] ++ setAiCustomTimeoutMs: (this._setAiCustomTimeoutMs.bind(this): any), ++ // $FlowFixMe[method-unbinding] ++ setAiCustomStreaming: (this._setAiCustomStreaming.bind(this): any), + }; + + componentDidMount() { ++ // Sync custom AI config on mount ++ setCustomEndpointConfig({ ++ enabled: this.state.values.aiCustomEndpointEnabled, ++ baseUrl: this.state.values.aiCustomBaseUrl, ++ apiKey: this.state.values.aiCustomApiKey, ++ model: this.state.values.aiCustomModel, ++ fallbackModel: this.state.values.aiCustomFallbackModel, ++ temperature: this.state.values.aiCustomTemperature, ++ timeoutMs: this.state.values.aiCustomTimeoutMs, ++ streaming: this.state.values.aiCustomStreaming, ++ }); ++ + this._periodicUpdateCheckTimeout = setTimeout( + () => this._checkUpdates(), + CHECK_APP_UPDATES_TIMEOUT +@@ -980,9 +984,11 @@ + + _persistValuesToLocalStorage(preferences: Preferences): any { + try { ++ // The API key is never persisted in cleartext (see ++ // `getPersistablePreferences`). + localStorage.setItem( + localStorageItem, +- JSON.stringify(preferences.values) ++ JSON.stringify(getPersistablePreferences(preferences.values)) + ); + } catch (e) { + console.warn('Unable to persist preferences', e); +@@ -1160,21 +1166,6 @@ + ); + } + +- _getNewEffectDialogDefaultTab(): any { +- return this.state.values.newEffectDialogDefaultTab; +- } +- +- _setNewEffectDialogDefaultTab( +- newEffectDialogDefaultTab: 'asset-store' | 'from-scratch' +- ) { +- this.setState( +- state => ({ +- values: { ...state.values, newEffectDialogDefaultTab }, +- }), +- () => this._persistValuesToLocalStorage(this.state) +- ); +- } +- + _getShareDialogDefaultTab(): any { + return this.state.values.shareDialogDefaultTab; + } +@@ -1508,6 +1499,125 @@ + ); + } + ++ _setAiCustomEndpointEnabled(newValue: boolean) { ++ this.setState( ++ state => ({ ++ values: { ++ ...state.values, ++ aiCustomEndpointEnabled: newValue, ++ }, ++ }), ++ () => { ++ this._persistValuesToLocalStorage(this.state); ++ setCustomEndpointConfig({ enabled: newValue }); ++ } ++ ); ++ } ++ ++ _setAiCustomBaseUrl(newValue: string) { ++ this.setState( ++ state => ({ ++ values: { ++ ...state.values, ++ aiCustomBaseUrl: newValue, ++ }, ++ }), ++ () => { ++ this._persistValuesToLocalStorage(this.state); ++ setCustomEndpointConfig({ baseUrl: newValue }); ++ } ++ ); ++ } ++ ++ _setAiCustomApiKey(newValue: string) { ++ this.setState( ++ state => ({ ++ values: { ++ ...state.values, ++ aiCustomApiKey: newValue, ++ }, ++ }), ++ () => { ++ setCustomEndpointConfig({ apiKey: newValue }); ++ } ++ ); ++ } ++ ++ _setAiCustomModel(newValue: string) { ++ this.setState( ++ state => ({ ++ values: { ++ ...state.values, ++ aiCustomModel: newValue, ++ }, ++ }), ++ () => { ++ this._persistValuesToLocalStorage(this.state); ++ setCustomEndpointConfig({ model: newValue }); ++ } ++ ); ++ } ++ ++ _setAiCustomFallbackModel(newValue: string) { ++ this.setState( ++ state => ({ ++ values: { ++ ...state.values, ++ aiCustomFallbackModel: newValue, ++ }, ++ }), ++ () => { ++ this._persistValuesToLocalStorage(this.state); ++ setCustomEndpointConfig({ fallbackModel: newValue }); ++ } ++ ); ++ } ++ ++ _setAiCustomTemperature(newValue: number) { ++ this.setState( ++ state => ({ ++ values: { ++ ...state.values, ++ aiCustomTemperature: newValue, ++ }, ++ }), ++ () => { ++ this._persistValuesToLocalStorage(this.state); ++ setCustomEndpointConfig({ temperature: newValue }); ++ } ++ ); ++ } ++ ++ _setAiCustomStreaming(newValue: boolean) { ++ this.setState( ++ state => ({ ++ values: { ++ ...state.values, ++ aiCustomStreaming: newValue, ++ }, ++ }), ++ () => { ++ this._persistValuesToLocalStorage(this.state); ++ setCustomEndpointConfig({ streaming: newValue }); ++ } ++ ); ++ } ++ ++ _setAiCustomTimeoutMs(newValue: number) { ++ this.setState( ++ state => ({ ++ values: { ++ ...state.values, ++ aiCustomTimeoutMs: newValue, ++ }, ++ }), ++ () => { ++ this._persistValuesToLocalStorage(this.state); ++ setCustomEndpointConfig({ timeoutMs: newValue }); ++ } ++ ); ++ } ++ + render(): any { + return ( + +--- a/newIDE/app/src/MainFrame/Preferences/PreferencesStorage.js ++++ b/newIDE/app/src/MainFrame/Preferences/PreferencesStorage.js +@@ -0,0 +1,65 @@ ++// @flow ++ ++/** ++ * The preferences that may be written to localStorage. ++ * ++ * `aiCustomApiKey` is deliberately omitted: the user's API key must never be ++ * persisted in cleartext (it is kept in memory only, for the session). Both the ++ * preferences payload and the custom-AI config payload drop it — see ++ * `CustomAIClient.setCustomEndpointConfig`, which does the same for its own ++ * store. ++ * ++ * A pure module so the exclusion can be unit-tested: `PreferencesProvider.js` ++ * imports React, Electron and the GDevelop core, which a spec cannot load. ++ */ ++export const getPersistablePreferences = (values: Object): Object => { ++ const { aiCustomApiKey, ...persistableValues } = values; ++ return persistableValues; ++}; ++ ++/** ++ * Merge preferences read from localStorage with the defaults, mirroring the ++ * provider's upgrade path: a key that is *missing* (undefined) is backfilled ++ * from the defaults; a key that is present is kept as-is, even if falsy or ++ * wrong-typed (the app validates at the point of use, not here). Also applies ++ * the two stored-value migrations (renamed themes, the deprecated-warning ++ * tri-state). Returns null when the stored value is not a plain object, which ++ * the caller treats as "no stored preferences". ++ * ++ * Extracted from `PreferencesProvider.loadPreferencesFromLocalStorage` so the ++ * backfill contract can be unit-tested (that file imports React, Electron and ++ * the GDevelop core). ++ */ ++export const mergeStoredPreferencesWithDefaults = ( ++ storedValues: any, ++ defaults: Object ++): ?Object => { ++ if ( ++ !storedValues || ++ typeof storedValues !== 'object' || ++ Array.isArray(storedValues) ++ ) { ++ return null; ++ } ++ const values = { ...storedValues }; ++ for (const key in defaults) { ++ if ( ++ Object.prototype.hasOwnProperty.call(defaults, key) && ++ typeof values[key] === 'undefined' ++ ) { ++ values[key] = defaults[key]; ++ } ++ } ++ // Migrate renamed themes. ++ if (values.themeName === 'GDevelop default') { ++ values.themeName = 'GDevelop default Light'; ++ } else if (values.themeName === 'Dark') { ++ values.themeName = 'Blue Dark'; ++ } ++ if (typeof values.showDeprecatedInstructionWarning === 'boolean') { ++ values.showDeprecatedInstructionWarning = values.showDeprecatedInstructionWarning ++ ? 'icon' ++ : 'no'; ++ } ++ return values; ++}; diff --git a/patches/README.md b/patches/README.md new file mode 100644 index 0000000..1fd295d --- /dev/null +++ b/patches/README.md @@ -0,0 +1,39 @@ +# patches/ — Tier-1 entitlement unlocks (Phase 2, issue #2) + +Stock upstream `app.asar` + these hunks = fork parity for Tier-1, with no +205-file merge treadmill. Applied by `scripts/apply-patches.mjs` +(extract -> patch -> repack); every hunk is gated by the Phase 1 seam suite. + +## Files + +| Patch | Upstream target(s) | Unlock | +|---|---|---| +| `01-usage-predicate.patch` | `Utils/GDevelopServices/Usage.js` | `hasValidSubscriptionPlan` -> `return true`; seeds `UNLOCKED_HERETEK_SUBSCRIPTION` / `UNLOCKED_HERETEK_CAPABILITIES` (`planId: 'gdevelop_startup'`) | +| `02-auth-context-seed.patch` | `Profile/AuthenticatedUserContext.js` | Import + logged-in/logged-out default seeds | +| `03-subscription-checker.patch` | `Profile/Subscription/SubscriptionChecker.js` | Both closures stubbed to `return true` | +| `04-loading-screen.patch` | `ProjectManager/LoadingScreenEditor.js` | Logo guard + watermark guard/clamp deleted | +| `05-preview-debugger.patch` | `.../LocalPreviewLauncher/index.js`, `.../DebuggerEditorContainer.js` | `% 16` block deleted; network-preview + debugger stubs | +| `06-electron-hook.patch` | `electron-app/app/main.js` (loader line only) + `launcher/*.cjs` sync by the driver | Main-process hook install before the `BrowserWindow` destructure | +| `07-generation-glue.patch` | `Utils/GDevelopServices/Generation.js` (verbatim fork delta) | Tier-2 branch points: `local-ai-*` / `isCustomEndpointEnabled()` routing into the extracted client | +| `08-preferences-ai.patch` | `Preferences{Dialog,Context,Provider}.js` (verbatim fork deltas) + fork-only `PreferencesStorage.js` (new file) | `ai` preferences tab + persistable-preferences leaf (API key never persisted) | + +Out of scope: local HTML5/desktop exports (never gated upstream); +online-build quotas (server-enforced); the duration-slider gate +(`LoadingScreenEditor` ~:387, fork-kept). + +## Regeneration + +Patches are generated, not hand-edited — anchors assert a single match +and fail loudly on drift: + +```bash +node scripts/generate-tier1-patches.mjs --upstream review/GDevelop +``` + +Each generated patch round-trips (`patch -p1 --dry-run` on a pristine +copy) before it is written. Patched output matches the fork byte-for-byte +on 3 of 6 files; the only residuals are intentional non-Tier-1 exclusions: + +- `Usage.js` quota type fields (`nextCreditsBackAt`, …) — fork cleanup +- `AuthenticatedUserContext.js` `type` import tweak — fork cleanup +- `LocalPreviewLauncher` `previewContext`/`sourceGameId` — upstream-sync drift diff --git a/scripts/apply-patches.mjs b/scripts/apply-patches.mjs new file mode 100644 index 0000000..c3f9870 --- /dev/null +++ b/scripts/apply-patches.mjs @@ -0,0 +1,387 @@ +// Phase 2 driver (issue #2): stock upstream tree -> apply `patches/*.patch`. +// +// node scripts/apply-patches.mjs --check +// Seam gate only (Phase 1 suite). No filesystem changes. +// +// node scripts/apply-patches.mjs --target +// Extract-free mode: `patch -p1 --dry-run` every hunk first (loud abort +// with the failing hunk on drift), then apply, then verify unlock +// markers. Re-running against an already-patched target FAILS +// (patches are not idempotent by design — a green second run would +// mean the hunks never matched). +// +// node scripts/apply-patches.mjs --asar app.asar --out app.patched.asar +// Full CI path: extract with the Electron `asar` tool, apply as above, +// repack. NOTE: a production asar holds bundled/minified JS, so the +// source-level hunks in patches/ will NOT match bundle output — the +// driver says so loudly instead of pretending. Bundle-mapped hunks +// must be authored from a real `electron-app` build before this mode +// can go green (tracked in issue #2 acceptance: smoke launch). +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(here, '..'); +const PATCHES_DIR = path.join(ROOT, 'patches'); +const LAUNCHER_DIR = path.join(ROOT, 'launcher'); +const LAUNCHER_FILES = [ + 'heretek-hook.cjs', + 'preload-unlock.cjs', + 'unlock-payloads.cjs', +]; + +// Post-apply contract: what a correctly patched tree must (and must not) +// contain. `excludes` are chosen to avoid fork-kept residuals (e.g. +// LoadingScreenEditor still references `subscriptionChecker` for other +// paywalled settings; preview/debugger still assign checker refs). +const MARKERS = [ + { + patch: '01-usage-predicate.patch', + file: 'newIDE/app/src/Utils/GDevelopServices/Usage.js', + includes: [ + 'export const UNLOCKED_HERETEK_SUBSCRIPTION: Subscription', + "planId: 'gdevelop_startup'", + 'export const UNLOCKED_HERETEK_CAPABILITIES: Capabilities', + '// In GDevelop-Heretek, all client-side and offline features are unlocked for all users.\n return true;', + ], + excludes: ['redemptionCodeValidUntil > Date.now()'], + }, + { + patch: '02-auth-context-seed.patch', + file: 'newIDE/app/src/Profile/AuthenticatedUserContext.js', + includes: [ + 'UNLOCKED_HERETEK_SUBSCRIPTION,\n UNLOCKED_HERETEK_CAPABILITIES,', + 'subscription: UNLOCKED_HERETEK_SUBSCRIPTION,', + 'capabilities: UNLOCKED_HERETEK_CAPABILITIES,', + ], + excludes: ['subscription: null,', 'limits: null,'], + }, + { + patch: '03-subscription-checker.patch', + file: 'newIDE/app/src/Profile/Subscription/SubscriptionChecker.js', + includes: [ + 'const checkUserHasSubscription = () => {\n setDialogOpen(false);\n return true;', + 'const hasUserSubscription = () => {\n return true;', + ], + excludes: [ + 'hasValidSubscriptionPlan', + 'AuthenticatedUserContext', + 'isNativeMobileApp', + 'sendSubscriptionCheckDialogShown', + 'setDialogOpen(true)', + ], + }, + { + patch: '04-loading-screen.patch', + file: 'newIDE/app/src/ProjectManager/LoadingScreenEditor.js', + includes: [ + 'loadingScreen.showGDevelopLogoDuringLoadingScreen(checked);\n onUpdate();', + 'watermark.showGDevelopWatermark(checked);\n onUpdate();', + ], + excludes: [ + // NOTE: the duration-slider gate (~:387, `newMinDuration < + // forcedLogo.minDuration`) is fork-kept behavior, out of scope — + // only the watermark-disable write-back is stubbed. + 'setMinDuration(forcedLogo.minDuration)', + '// If user wants to deactivate GDevelop splash screen although', + '// If user wants to deactivate watermark although GDevelop splash', + ], + }, + { + patch: '05-preview-debugger.patch', + file: + 'newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/index.js', + includes: [ + ' _checkSubscriptionForNetworkPreview = (): any => {\n return true;', + ], + excludes: [ + 'hotReloadsCount % 16', + 'this._hotReloadSubscriptionChecker.checkUserHasSubscription()', + 'this._networkPreviewSubscriptionChecker.checkUserHasSubscription()', + ], + }, + { + patch: '05-preview-debugger.patch', + file: 'newIDE/app/src/MainFrame/EditorContainers/DebuggerEditorContainer.js', + includes: ['// Unlocked in GDevelop-Heretek'], + excludes: ['this._subscriptionChecker.checkUserHasSubscription()'], + }, + { + patch: '06-electron-hook.patch', + file: 'newIDE/electron-app/app/main.js', + includes: [ + "require('./heretek-hook.cjs').installPreloadHook();", + 'const newWindow = new BrowserWindow(options);', + ], + excludes: [], + }, + { + patch: '07-generation-glue.patch', + file: 'newIDE/app/src/Utils/GDevelopServices/Generation.js', + includes: [ + 'isCustomEndpointEnabled', + "startsWith('local-ai-')", + 'customCreateAiRequest', + ], + excludes: [], + }, + { + patch: '08-preferences-ai.patch', + file: 'newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js', + includes: ["'preferences' | 'ai' | 'shortcuts'"], + excludes: [], + }, + { + patch: '08-preferences-ai.patch', + file: 'newIDE/app/src/MainFrame/Preferences/PreferencesStorage.js', + includes: ['getPersistablePreferences', 'aiCustomApiKey'], + excludes: [], + }, +]; + +function parseArgs(argv) { + const out = {}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--check') out.check = true; + else if (a === '--target') out.target = path.resolve(argv[++i]); + else if (a === '--asar') out.asar = path.resolve(argv[++i]); + else if (a === '--out') out.out = path.resolve(argv[++i]); + else if (a === '--no-verify') out.noVerify = true; + } + return out; +} + +function runSeamTests() { + console.log('[apply-patches] running Phase 1 seam suite...'); + // The node:test child-process detector (NODE_TEST_CONTEXT) would make a + // `node --test` spawned from inside a test run silently skip. Strip it + // so the gate really runs even when the driver is invoked by the suite. + const env = Object.fromEntries( + Object.entries(process.env).filter(([k]) => k !== 'NODE_TEST_CONTEXT') + ); + const r = spawnSync('node', ['--test', 'tests/seams/*.test.mjs'], { + cwd: ROOT, + shell: true, + stdio: 'inherit', + env, + }); + if (r.status !== 0) { + console.error( + '[apply-patches] SEAM SUITE RED — upstream drifted. See file:line ' + + 'pointers above; regenerate patches/ before repacking.' + ); + process.exit(r.status ?? 1); + } + console.log('[apply-patches] seam suite green.'); +} + +function listPatches() { + if (!fs.existsSync(PATCHES_DIR)) return []; + return fs + .readdirSync(PATCHES_DIR) + .filter(f => f.endsWith('.patch')) + .sort() + .map(f => path.join(PATCHES_DIR, f)); +} + +function runPatch(cwd, patchFile, dryRun) { + const args = ['-p1', dryRun ? '--dry-run' : '-s', '-i', patchFile]; + return spawnSync('patch', args, { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf8', + }); +} + +function applyToTarget(target) { + const patches = listPatches(); + if (patches.length === 0) { + console.error('[apply-patches] no patches/*.patch found. Nothing to do.'); + process.exit(2); + } + if (!fs.existsSync(path.join(target, 'newIDE'))) { + console.error( + `[apply-patches] target ${target} has no newIDE/ — pass an upstream ` + + 'checkout root (or extracted asar with newIDE/ at top level).' + ); + process.exit(2); + } + // Gate 1: every hunk dry-runs clean before anything is touched. + for (const p of patches) { + const r = runPatch(target, p, true); + if (r.status !== 0) { + console.error( + `[apply-patches] DRY-RUN FAILED: ${path.basename(p)}\n` + + `${r.stdout}${r.stderr}\n` + + 'Upstream drifted under this hunk. Regenerate with ' + + 'node scripts/generate-tier1-patches.mjs --upstream .' + ); + process.exit(1); + } + console.log(`[apply-patches] dry-run clean: ${path.basename(p)}`); + } + for (const p of patches) { + const r = runPatch(target, p, false); + if (r.status !== 0) { + console.error( + `[apply-patches] APPLY FAILED: ${path.basename(p)}\n` + + `${r.stdout}${r.stderr}` + ); + process.exit(1); + } + console.log(`[apply-patches] applied: ${path.basename(p)}`); + } +} + +/** + * Copy launcher/*.cjs next to the patched main.js (patch 06 requires + * ./heretek-hook.cjs relative to electron-app/app/). Runs after every + * successful apply so the loader line can never dangle. + */ +function syncLauncherFiles(target) { + const destDir = path.join(target, 'newIDE/electron-app/app'); + if (!fs.existsSync(destDir)) { + console.error( + `[apply-patches] cannot sync launcher files: ${destDir} missing.` + ); + process.exit(2); + } + for (const file of LAUNCHER_FILES) { + const src = path.join(LAUNCHER_DIR, file); + if (!fs.existsSync(src)) { + console.error(`[apply-patches] launcher file missing: ${src}`); + process.exit(2); + } + fs.copyFileSync(src, path.join(destDir, file)); + console.log(`[apply-patches] synced launcher/${file}`); + } +} + +function verifyTarget(target) { + let failed = 0; + for (const file of LAUNCHER_FILES) { + const abs = path.join(target, 'newIDE/electron-app/app', file); + if (!fs.existsSync(abs)) { + console.error(`[verify] MISSING launcher file: electron-app/app/${file}`); + failed++; + continue; + } + const src = fs.readFileSync(path.join(LAUNCHER_DIR, file), 'utf8'); + if (fs.readFileSync(abs, 'utf8') !== src) { + console.error(`[verify] STALE launcher file: electron-app/app/${file}`); + failed++; + } + } + for (const m of MARKERS) { + const abs = path.join(target, m.file); + if (!fs.existsSync(abs)) { + console.error(`[verify] MISSING file: ${m.file} (${m.patch})`); + failed++; + continue; + } + const text = fs.readFileSync(abs, 'utf8'); + for (const inc of m.includes) { + if (!text.includes(inc)) { + console.error( + `[verify] MISSING marker in ${m.file} (${m.patch}):\n${inc}` + ); + failed++; + } + } + for (const exc of m.excludes) { + if (text.includes(exc)) { + console.error( + `[verify] LEFTOVER gate in ${m.file} (${m.patch}):\n${exc}` + ); + failed++; + } + } + } + if (failed > 0) { + console.error(`[verify] ${failed} marker check(s) failed.`); + process.exit(1); + } + console.log('[verify] all Tier-1 unlock markers present, gates gone.'); +} + +function extractAsar(asar, dest) { + const r = spawnSync( + 'npx', + ['--yes', '@electron/asar', 'extract', asar, dest], + { stdio: 'inherit' } + ); + if (r.status !== 0) { + console.error( + '[apply-patches] asar extraction failed. Install it with ' + + '`npm i -g @electron/asar` (npm only, per repo policy) and retry.' + ); + process.exit(1); + } +} + +function packAsar(src, out) { + const r = spawnSync('npx', ['--yes', '@electron/asar', 'pack', src, out], { + stdio: 'inherit', + }); + if (r.status !== 0) { + console.error('[apply-patches] asar repack failed.'); + process.exit(1); + } +} + +const args = parseArgs(process.argv.slice(2)); + +if (args.check && !args.target && !args.asar) { + runSeamTests(); + process.exit(0); +} + +runSeamTests(); + +if (args.asar) { + if (!args.out) { + console.error( + 'Usage: node scripts/apply-patches.mjs --asar --out ' + ); + process.exit(2); + } + const workdir = fs.mkdtempSync(path.join(os.tmpdir(), 'gdevelop-unlock-')); + try { + console.log(`[apply-patches] extracting ${args.asar} ...`); + extractAsar(args.asar, workdir); + console.log( + '[apply-patches] NOTE: production asars hold bundled/minified JS; ' + + 'source-level hunks are expected to fail here until bundle-mapped ' + + 'hunks are authored from a real electron-app build (issue #2).' + ); + applyToTarget(workdir); + syncLauncherFiles(workdir); + if (!args.noVerify) verifyTarget(workdir); + // Auto-updater pin (issue #3 pre-note): never ship a repacked asar + // without recording which upstream version it came from. + packAsar(workdir, args.out); + console.log(`[apply-patches] repacked: ${args.out}`); + } finally { + fs.rmSync(workdir, { recursive: true, force: true }); + } + process.exit(0); +} + +if (args.target) { + applyToTarget(args.target); + syncLauncherFiles(args.target); + if (!args.noVerify) verifyTarget(args.target); + process.exit(0); +} + +console.error( + 'Usage:\n' + + ' node scripts/apply-patches.mjs --check\n' + + ' node scripts/apply-patches.mjs --target \n' + + ' node scripts/apply-patches.mjs --asar --out ' +); +process.exit(2); diff --git a/scripts/extract-tier2.mjs b/scripts/extract-tier2.mjs new file mode 100644 index 0000000..abc645e --- /dev/null +++ b/scripts/extract-tier2.mjs @@ -0,0 +1,88 @@ +// Extracts Tier-2 donor files VERBATIM from the fork into +// packages/ai-studio/ (Phase 4, issue #4). The fork is a donor, not a +// delivery vehicle: these files are never hand-edited here. +// +// node scripts/extract-tier2.mjs --fork review/GDevelop-Heretek +// +// Writes MANIFEST.json (sha256 per file); tests/tier2/extract.test.mjs +// re-verifies byte-identity. To refresh after a fork sync, re-run with +// the new fork checkout — any content change shows up as a manifest +// diff in code review. +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(here, '..'); +const DEST = path.join(ROOT, 'packages', 'ai-studio'); + +// Donor paths, relative to the fork root. Kept explicit (not a glob) +// so additions/removals in the fork are noticed, not silently absorbed. +const DONOR_FILES = [ + 'newIDE/app/src/AI/CustomAIClient.js', + 'newIDE/app/src/AI/CustomAIClient.spec.js', + 'newIDE/app/src/AiGeneration/Studio/EditApprovalLabel.js', + 'newIDE/app/src/AiGeneration/Studio/FinalizeSubAgents.js', + 'newIDE/app/src/AiGeneration/Studio/LoopGuard.js', + 'newIDE/app/src/AiGeneration/Studio/NudgePolicy.js', + 'newIDE/app/src/AiGeneration/Studio/PlanStore.js', + 'newIDE/app/src/AiGeneration/Studio/RequestWriteGate.js', + 'newIDE/app/src/AiGeneration/Studio/Roles.js', + 'newIDE/app/src/AiGeneration/Studio/RoleToolPolicy.js', + 'newIDE/app/src/AiGeneration/Studio/SafeTruncation.js', + 'newIDE/app/src/AiGeneration/Studio/SpawnSubAgents.js', + 'newIDE/app/src/AiGeneration/Studio/UseStudioNudge.js', + 'newIDE/app/src/AiGeneration/Studio/UseStudioRuntime.js', + 'scripts/dev/mock-ai-provider.js', + 'scripts/dev/mock-script.example.json', + 'scripts/dev/mock-script-multi.json', + 'scripts/dev/mock-script-specialists.json', + 'scripts/dev/mock-child-scripts.example.json', +]; + +// Fork-relative -> package-relative layout. +function destFor(rel) { + if (rel.startsWith('newIDE/app/src/')) + return rel.slice('newIDE/app/src/'.length); + if (rel.startsWith('scripts/dev/')) + return 'dev-harness/' + rel.slice('scripts/dev/'.length); + throw new Error(`No destination mapping for donor file: ${rel}`); +} + +function parseArgs(argv) { + const out = { fork: path.join(ROOT, 'review', 'GDevelop-Heretek') }; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--fork') out.fork = path.resolve(argv[++i]); + } + return out; +} + +const args = parseArgs(process.argv.slice(2)); +const manifest = { donor: args.fork, files: {} }; + +for (const rel of DONOR_FILES) { + const src = path.join(args.fork, rel); + if (!fs.existsSync(src)) { + throw new Error( + `Donor file missing: ${rel} (fork checkout at ${args.fork}). ` + + `If the fork moved it, update DONOR_FILES explicitly.` + ); + } + const content = fs.readFileSync(src); + const dest = path.join(DEST, destFor(rel)); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.writeFileSync(dest, content); + manifest.files[destFor(rel)] = { + donor: rel, + bytes: content.length, + sha256: crypto.createHash('sha256').update(content).digest('hex'), + }; + console.log(`extracted ${destFor(rel)} (${content.length} bytes)`); +} + +fs.writeFileSync( + path.join(DEST, 'MANIFEST.json'), + JSON.stringify(manifest, null, 2) + '\n' +); +console.log(`wrote MANIFEST.json (${DONOR_FILES.length} files)`); diff --git a/scripts/generate-tier1-patches.mjs b/scripts/generate-tier1-patches.mjs new file mode 100644 index 0000000..23b8a97 --- /dev/null +++ b/scripts/generate-tier1-patches.mjs @@ -0,0 +1,490 @@ +// Generates patches/*.patch (Tier-1 unlocks, Phase 2) from a stock upstream +// checkout by applying the exact Tier-1 replacements. +// +// node scripts/generate-tier1-patches.mjs --upstream review/GDevelop +// +// Every replacement asserts a single match (loud on drift). Non-Tier-1 fork +// deltas are deliberately NOT reproduced (Usage quota type fields, +// LocalPreviewLauncher previewContext/sourceGameId, the FirebaseUser +// `type` import tweak) — verify with: diff the patched tree against the +// fork and confirm only those exclusions remain. +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(here, '..'); +const requireCjs = createRequire(import.meta.url); +const payloads = requireCjs('../launcher/unlock-payloads.cjs'); + +/** + * Serialize a plain-JS payload object to prettier-style source + * (single quotes, 2-space indent, es5 trailing commas) — byte-identical + * to the fork's UNLOCKED_* blocks, which were written in that style. + */ +function serializeJs(value, indent) { + if (Array.isArray(value)) { + if (value.length === 0) return '[]'; + const pad = ' '.repeat(indent); + const inner = ' '.repeat(indent + 2); + return `[\n${value.map(v => `${inner}${serializeJs(v, indent + 2)}`).join(',\n')},\n${pad}]`; + } + if (value && typeof value === 'object') { + const keys = Object.keys(value); + if (keys.length === 0) return '{}'; + const pad = ' '.repeat(indent); + const inner = ' '.repeat(indent + 2); + return `{\n${keys.map(k => `${inner}${k}: ${serializeJs(value[k], indent + 2)}`).join(',\n')},\n${pad}}`; + } + if (typeof value === 'string') + return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; + return String(value); +} + +function parseArgs(argv) { + const out = { upstream: path.join(ROOT, 'review', 'GDevelop') }; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--upstream') out.upstream = path.resolve(argv[++i]); + else if (argv[i] === '--out') out.out = path.resolve(argv[++i]); + else out.out = path.join(ROOT, 'patches'); + } + if (!out.out) out.out = path.join(ROOT, 'patches'); + return out; +} + +function replaceOnce(text, oldStr, newStr, label) { + const count = text.split(oldStr).length - 1; + if (count !== 1) { + throw new Error( + `GENERATOR DRIFT [${label}]: expected 1 match, found ${count}. ` + + `Upstream moved — update the replacement anchor.` + ); + } + return text.replace(oldStr, newStr); +} + +const UNLOCKED_SUBSCRIPTION_BLOCK = + 'export const UNLOCKED_HERETEK_SUBSCRIPTION: Subscription = ' + + serializeJs(payloads.UNLOCKED_SUBSCRIPTION, 0) + + ';\n\n' + + 'export const UNLOCKED_HERETEK_CAPABILITIES: Capabilities = ' + + serializeJs(payloads.UNLOCKED_CAPABILITIES, 0) + + ';'; + +const LIMITS_SEED = `limits: { + quotas: {}, + capabilities: UNLOCKED_HERETEK_CAPABILITIES, + credits: { + userBalance: { amount: 999999 }, + prices: {}, + purchasableQuantities: [], + }, + message: undefined, + },`; + +// Each entry: files touched, and a transform(upstreamText) -> patchedText. +const PATCHES = [ + { + name: '01-usage-predicate.patch', + files: ['newIDE/app/src/Utils/GDevelopServices/Usage.js'], + transform(text) { + let out = replaceOnce( + text, + ` return !!subscription && subscription.pricingSystemId === 'TEAM_MEMBER'; +}; + +export const hasValidSubscriptionPlan = (`, + ` return !!subscription && subscription.pricingSystemId === 'TEAM_MEMBER'; +}; + +${UNLOCKED_SUBSCRIPTION_BLOCK} + +export const hasValidSubscriptionPlan = (`, + '01/seed-insert' + ); + out = replaceOnce( + out, + `export const hasValidSubscriptionPlan = ( + subscription: ?Subscription +): boolean => { + const hasValidSubscription = + !!subscription && + !!subscription.planId && + (!subscription.redemptionCodeValidUntil || // No redemption code + subscription.redemptionCodeValidUntil > Date.now()); // Redemption code is still valid + + if (hasValidSubscription) { + // The user has a subscription registered in the backend (classic "Registered" user). + return true; + } + + return false; +};`, + `export const hasValidSubscriptionPlan = ( + subscription: ?Subscription +): boolean => { + // In GDevelop-Heretek, all client-side and offline features are unlocked for all users. + return true; +};`, + '01/predicate-stub' + ); + return out; + }, + }, + { + name: '02-auth-context-seed.patch', + files: ['newIDE/app/src/Profile/AuthenticatedUserContext.js'], + transform(text) { + let out = replaceOnce( + text, + ` type UserEarningsBalance, +} from '../Utils/GDevelopServices/Usage';`, + ` type UserEarningsBalance, + UNLOCKED_HERETEK_SUBSCRIPTION, + UNLOCKED_HERETEK_CAPABILITIES, +} from '../Utils/GDevelopServices/Usage';`, + '02/import' + ); + out = replaceOnce( + out, + ` subscription: null, + subscriptionPricingSystem: null, + usages: null, + userEarningsBalance: null, + limits: null, + ...authenticatedUserPropertiesLoadingState,`, + ` subscription: UNLOCKED_HERETEK_SUBSCRIPTION, + subscriptionPricingSystem: null, + usages: null, + userEarningsBalance: null, + ${LIMITS_SEED} + ...authenticatedUserPropertiesLoadingState,`, + '02/logged-in-seed' + ); + out = replaceOnce( + out, + ` receivedBundles: ([]: Array), // Initialize to empty array to indicate that the loading is done. + subscription: null, + subscriptionPricingSystem: null, + userEarningsBalance: null, + usages: null, + limits: null, +};`, + ` receivedBundles: ([]: Array), // Initialize to empty array to indicate that the loading is done. + subscription: UNLOCKED_HERETEK_SUBSCRIPTION, + subscriptionPricingSystem: null, + userEarningsBalance: null, + usages: null, + ${LIMITS_SEED} +};`, + '02/logged-out-seed' + ); + return out; + }, + }, + { + name: '03-subscription-checker.patch', + files: ['newIDE/app/src/Profile/Subscription/SubscriptionChecker.js'], + transform(text) { + let out = replaceOnce( + text, + `// @flow + +import * as React from 'react'; +import { Trans } from '@lingui/macro'; +import FlatButton from '../../UI/FlatButton'; +import Dialog from '../../UI/Dialog'; +import AuthenticatedUserContext from '../AuthenticatedUserContext'; +import { Column } from '../../UI/Grid'; +import { + sendSubscriptionCheckDialogShown, + sendSubscriptionCheckDismiss, +} from '../../Utils/Analytics/EventSender'; +import Text from '../../UI/Text'; +import { hasValidSubscriptionPlan } from '../../Utils/GDevelopServices/Usage'; +import { isNativeMobileApp } from '../../Utils/Platform'; +import InAppTutorialContext from '../../InAppTutorial/InAppTutorialContext'; +import GetSubscriptionCard from './GetSubscriptionCard';`, + `// @flow +import * as React from 'react'; +import { Trans } from '@lingui/macro'; +import FlatButton from '../../UI/FlatButton'; +import Dialog from '../../UI/Dialog'; +import { Column } from '../../UI/Grid'; +import { sendSubscriptionCheckDismiss } from '../../Utils/Analytics/EventSender'; +import Text from '../../UI/Text'; +import GetSubscriptionCard from './GetSubscriptionCard';`, + '03/imports' + ); + out = replaceOnce( + out, + `}> = React.forwardRef( + ( + { + mode, + id, + title, + onChangeSubscription, + placementId, + isNotShownDuringInAppTutorial, + }, + ref + ) => { + const authenticatedUser = React.useContext(AuthenticatedUserContext); + const [dialogOpen, setDialogOpen] = React.useState(false);`, + `}> = React.forwardRef( + ({ mode, id, title, onChangeSubscription, placementId }, ref) => { + const [dialogOpen, setDialogOpen] = React.useState(false);`, + '03/signature' + ); + out = replaceOnce( + out, + ` const { currentlyRunningInAppTutorial } = React.useContext( + InAppTutorialContext + ); + + const checkUserHasSubscription = () => {`, + ` const checkUserHasSubscription = () => {`, + '03/tutorial-context' + ); + out = replaceOnce( + out, + ` const checkUserHasSubscription = () => { + if ( + hasValidSubscriptionPlan(authenticatedUser.subscription) || + (isNotShownDuringInAppTutorial && currentlyRunningInAppTutorial) + ) { + setDialogOpen(false); + return true; + } + + if (isNativeMobileApp()) { + // Would present App Store screen. + } else { + setDialogOpen(true); + sendSubscriptionCheckDialogShown({ mode, id }); + } + + return false; + }; + + const hasUserSubscription = () => { + return hasValidSubscriptionPlan(authenticatedUser.subscription); + };`, + ` const checkUserHasSubscription = () => { + setDialogOpen(false); + return true; + }; + + const hasUserSubscription = () => { + return true; + };`, + '03/closures' + ); + return out; + }, + }, + { + name: '04-loading-screen.patch', + files: ['newIDE/app/src/ProjectManager/LoadingScreenEditor.js'], + transform(text) { + let out = replaceOnce( + text, + ` onCheck={(e, checked) => { + if ( + !checked && + !watermark.isGDevelopWatermarkShown() && + subscriptionChecker.current && + !subscriptionChecker.current.checkUserHasSubscription() + ) { + // If user wants to deactivate GDevelop splash screen although + // watermark is hidden, we don't allow it if they have no subscription. + return; + } + loadingScreen.showGDevelopLogoDuringLoadingScreen(checked);`, + ` onCheck={(e, checked) => { + loadingScreen.showGDevelopLogoDuringLoadingScreen(checked);`, + '04/logo-guard' + ); + // Watermark guard + forced-duration clamp: remove everything from the + // guard `if` through the clamp, keeping only the show + onUpdate lines. + const guardStartMarker = ` if ( + !checked && + !loadingScreen.isGDevelopLogoShownDuringLoadingScreen() &&`; + const showLine = ` watermark.showGDevelopWatermark(checked);`; + const clampTail = ` + onUpdate();`; + const guardStartIdx = out.indexOf(guardStartMarker); + if (guardStartIdx === -1) + throw new Error('GENERATOR DRIFT [04/watermark-guard]: no match.'); + if (out.indexOf(guardStartMarker, guardStartIdx + 1) !== -1) + throw new Error( + 'GENERATOR DRIFT [04/watermark-guard]: anchor not unique.' + ); + const tailIdx = out.indexOf(clampTail, guardStartIdx); + if (tailIdx === -1) + throw new Error('GENERATOR DRIFT [04/watermark-tail]: no match.'); + const removed = out.slice(guardStartIdx, tailIdx); + for (const marker of [ + 'checkUserHasSubscription()', + 'if (checked) {', + 'forcedLogo.minDuration', + 'setMinDuration(forcedLogo.minDuration)', + 'hasUserSubscription()', + 'watermark.showGDevelopWatermark(checked);', + ]) { + if (!removed.includes(marker)) + throw new Error( + `GENERATOR DRIFT [04/clamp-span]: missing ${JSON.stringify(marker)}.` + ); + } + out = out.slice(0, guardStartIdx) + showLine + out.slice(tailIdx); + return out; + }, + }, + { + name: '05-preview-debugger.patch', + files: [ + 'newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/index.js', + 'newIDE/app/src/MainFrame/EditorContainers/DebuggerEditorContainer.js', + ], + transform(text, relPath) { + if (relPath.endsWith('LocalPreviewLauncher/index.js')) { + let out = replaceOnce( + text, + ` if (!previewOptions.isForInGameEdition) { + if ( + this.state.hotReloadsCount % 16 === 0 && + this._hotReloadSubscriptionChecker + ) { + this._hotReloadSubscriptionChecker.checkUserHasSubscription(); + } + this.setState(state => ({`, + ` if (!previewOptions.isForInGameEdition) { + this.setState(state => ({`, + '05/hot-reload-gate' + ); + out = replaceOnce( + out, + ` _checkSubscriptionForNetworkPreview = (): any => { + if (!this._networkPreviewSubscriptionChecker) return true; + + return this._networkPreviewSubscriptionChecker.checkUserHasSubscription(); + };`, + ` _checkSubscriptionForNetworkPreview = (): any => { + return true; + };`, + '05/network-stub' + ); + return out; + } + return replaceOnce( + text, + ` _checkUserHasSubscription() { + if ( + this._subscriptionChecker && + this.props.isActive && + !this.state.subscriptionChecked + ) { + this._subscriptionChecker.checkUserHasSubscription(); + this.setState({ + subscriptionChecked: true, + }); + } + }`, + ` _checkUserHasSubscription() { + // Unlocked in GDevelop-Heretek + }`, + '05/debugger-stub' + ); + }, + }, + { + name: '06-electron-hook.patch', + files: ['newIDE/electron-app/app/main.js'], + transform(text) { + // The loader MUST precede the `electron.BrowserWindow` destructure + // (main.js:6): the hook wraps the export before any module captures + // the original reference (main.js:226, PreviewWindow.js:52). + return replaceOnce( + text, + `const electron = require('electron'); +const path = require('path');`, + `// GDevelop-Unlocked (Phase 3, issue #3): install the Tier-1 runtime hook +// before any module destructures \`electron.BrowserWindow\` (main.js:6, +// PreviewWindow.js:1-6). Re-applied by scripts/apply-patches.mjs, which +// also syncs launcher/*.cjs next to this file. Updates drop the hook: +// see launcher/README.md ("auto-updater pin"). +try { + require('./heretek-hook.cjs').installPreloadHook(); +} catch (error) { + console.error( + '[heretek] hook install failed (Tier-1 runtime fallback only):', + error + ); +} +const electron = require('electron'); +const path = require('path');`, + '06/loader' + ); + }, + }, +]; + +const args = parseArgs(process.argv.slice(2)); +const outDir = args.out; +fs.mkdirSync(outDir, { recursive: true }); + +for (const patch of PATCHES) { + let combined = ''; + for (const rel of patch.files) { + const abs = path.join(args.upstream, rel); + if (!fs.existsSync(abs)) + throw new Error(`Upstream file missing: ${abs}`); + const upstreamText = fs.readFileSync(abs, 'utf8'); + const patchedText = patch.transform(upstreamText, rel); + if (patchedText === upstreamText) + throw new Error(`Patch ${patch.name} changed nothing in ${rel}.`); + const tmpOld = path.join('/tmp/opencode', `gen-old-${Date.now()}.txt`); + const tmpNew = path.join('/tmp/opencode', `gen-new-${Date.now()}.txt`); + fs.mkdirSync('/tmp/opencode', { recursive: true }); + fs.writeFileSync(tmpOld, upstreamText); + fs.writeFileSync(tmpNew, patchedText); + const r = spawnSync( + 'diff', + ['-U3', `--label=a/${rel}`, `--label=b/${rel}`, tmpOld, tmpNew], + { encoding: 'utf8' } + ); + fs.unlinkSync(tmpOld); + fs.unlinkSync(tmpNew); + if (r.status !== 1 || !r.stdout) + throw new Error( + `diff failed for ${rel} (status ${r.status}): ${r.stderr}` + ); + combined += r.stdout; + // Round-trip check: the hunk must apply cleanly to a pristine copy. + const checkDir = path.join('/tmp/opencode', `gen-rt-${Date.now()}`); + const checkFile = path.join(checkDir, rel); + fs.mkdirSync(path.dirname(checkFile), { recursive: true }); + fs.writeFileSync(checkFile, upstreamText); + const tmpPatch = path.join('/tmp/opencode', `gen-rt-${Date.now()}.patch`); + fs.writeFileSync(tmpPatch, r.stdout); + const dry = spawnSync('patch', ['-p1', '--dry-run', '-i', tmpPatch], { + cwd: checkDir, + encoding: 'utf8', + }); + fs.rmSync(checkDir, { recursive: true, force: true }); + fs.unlinkSync(tmpPatch); + if (dry.status !== 0) + throw new Error( + `Round-trip FAILED for ${patch.name} :: ${rel}:\n${dry.stdout}\n${dry.stderr}` + ); + } + fs.writeFileSync(path.join(outDir, patch.name), combined); + console.log( + `wrote ${patch.name} (${patch.files.length} file(s), round-trip clean)` + ); +} +console.log('done.'); diff --git a/scripts/generate-tier2-patches.mjs b/scripts/generate-tier2-patches.mjs new file mode 100644 index 0000000..13484a0 --- /dev/null +++ b/scripts/generate-tier2-patches.mjs @@ -0,0 +1,103 @@ +// Generates Tier-2 glue patches as VERBATIM fork deltas (Phase 4). +// Unlike Tier-1 (curated minimal hunks), the Generation + Preferences +// deltas are woven through their files, so the patch carries the exact +// fork diff — no hand-editing, no silent shaping. +// +// node scripts/generate-tier2-patches.mjs --upstream review/GDevelop --fork review/GDevelop-Heretek +// +// Each patch round-trips (`patch -p1 --dry-run` on a pristine copy) +// before it is written. Regeneration is deterministic: committed output +// must equal fresh output (pinned by tests/tier2/tier2-patches.test.mjs). +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(here, '..'); + +const PATCHES = [ + { + name: '07-generation-glue.patch', + files: ['newIDE/app/src/Utils/GDevelopServices/Generation.js'], + }, + { + name: '08-preferences-ai.patch', + files: [ + 'newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js', + 'newIDE/app/src/MainFrame/Preferences/PreferencesContext.js', + 'newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js', + // Fork-only: carried as a new-file hunk. + 'newIDE/app/src/MainFrame/Preferences/PreferencesStorage.js', + ], + }, +]; + +function parseArgs(argv) { + const out = { + upstream: path.join(ROOT, 'review', 'GDevelop'), + fork: path.join(ROOT, 'review', 'GDevelop-Heretek'), + out: path.join(ROOT, 'patches'), + }; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--upstream') out.upstream = path.resolve(argv[++i]); + else if (argv[i] === '--fork') out.fork = path.resolve(argv[++i]); + else if (argv[i] === '--out') out.out = path.resolve(argv[++i]); + } + return out; +} + +function diffFile(upstreamAbs, forkAbs, rel, isNew) { + const args = isNew + ? ['-U3', `--label=a/${rel}`, `--label=b/${rel}`, '/dev/null', forkAbs] + : ['-U3', `--label=a/${rel}`, `--label=b/${rel}`, upstreamAbs, forkAbs]; + const r = spawnSync('diff', args, { encoding: 'utf8' }); + if (r.status === 0) + throw new Error( + `No delta for ${rel} — fork stopped diverging there. ` + + `Drop it from the glue list instead of shipping an empty patch.` + ); + if (r.status !== 1 || !r.stdout) + throw new Error(`diff failed for ${rel} (status ${r.status}): ${r.stderr}`); + return r.stdout; +} + +const args = parseArgs(process.argv.slice(2)); +fs.mkdirSync(args.out, { recursive: true }); + +for (const patch of PATCHES) { + let combined = ''; + for (const rel of patch.files) { + const upstreamAbs = path.join(args.upstream, rel); + const forkAbs = path.join(args.fork, rel); + const isNew = !fs.existsSync(upstreamAbs); + if (!fs.existsSync(forkAbs)) + throw new Error(`Fork file missing: ${rel}`); + combined += diffFile(upstreamAbs, forkAbs, rel, isNew); + } + const checkDir = path.join('/tmp/opencode', `tier2-rt-${Date.now()}`); + fs.mkdirSync(checkDir, { recursive: true }); + const tmpPatch = path.join('/tmp/opencode', `tier2-rt-${Date.now()}.patch`); + fs.writeFileSync(tmpPatch, combined); + // Round-trip against pristine upstream copies of exactly these files. + for (const rel of patch.files) { + if (!fs.existsSync(path.join(args.upstream, rel))) continue; + const dest = path.join(checkDir, rel); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(path.join(args.upstream, rel), dest); + } + const dry = spawnSync('patch', ['-p1', '--dry-run', '-i', tmpPatch], { + cwd: checkDir, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + fs.rmSync(checkDir, { recursive: true, force: true }); + fs.unlinkSync(tmpPatch); + if (dry.status !== 0) + throw new Error( + `Round-trip FAILED for ${patch.name}:\n${dry.stdout}\n${dry.stderr}` + ); + fs.writeFileSync(path.join(args.out, patch.name), combined); + console.log(`wrote ${patch.name} (${patch.files.length} file(s), round-trip clean)`); +} +console.log('done.'); diff --git a/tests/launcher/hook.test.cjs b/tests/launcher/hook.test.cjs new file mode 100644 index 0000000..5d6d64a --- /dev/null +++ b/tests/launcher/hook.test.cjs @@ -0,0 +1,188 @@ +// Main-process hook tests (Phase 3): the whole heretek-hook.cjs +// surface driven with fake Electron objects — no Electron needed. +const { describe, it, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const hook = require('../../launcher/heretek-hook.cjs'); +const { + USAGE_API_BASE, +} = require('../../launcher/unlock-payloads.cjs'); + +function makeLogger() { + const records = { info: [], warn: [], error: [] }; + return { + logger: { + info: msg => records.info.push(String(msg)), + warn: msg => records.warn.push(String(msg)), + error: (msg, ...rest) => + records.error.push(String(msg) + ' ' + rest.join(' ')), + }, + records, + }; +} + +function makeFakeElectron() { + const listeners = {}; + const webRequests = []; + class FakeWindow { + constructor(options) { + this.options = options; + this.id = 7; + } + } + return { + electron: { + BrowserWindow: FakeWindow, + app: { on: (event, cb) => (listeners[event] = cb) }, + session: { + defaultSession: { + webRequest: { + onBeforeRequest: (filter, cb) => webRequests.push({ filter, cb }), + }, + }, + }, + }, + listeners, + webRequests, + FakeWindow, + }; +} + +describe('hook: window wrap', () => { + it('injects the preload into every constructed window', () => { + const { electron, FakeWindow } = makeFakeElectron(); + const { logger } = makeLogger(); + const result = hook.installPreloadHook(electron, { + logger, + skipUpdaterWatch: true, + }); + assert.equal(result.wrapped, true); + + const win = new electron.BrowserWindow({ width: 800 }); + assert.ok(win instanceof FakeWindow, 'must still construct the original'); + assert.ok( + String(win.options.webPreferences.preload).endsWith( + 'preload-unlock.cjs' + ) + ); + assert.equal(win.options.width, 800); + }); + + it('never overrides an explicitly set preload', () => { + const out = hook.withUnlockPreload({ + webPreferences: { preload: '/custom/preload.js' }, + }); + assert.equal(out.webPreferences.preload, '/custom/preload.js'); + }); + + it('wrap is idempotent across double install', () => { + const { electron } = makeFakeElectron(); + const { logger } = makeLogger(); + hook.installPreloadHook(electron, { logger, skipUpdaterWatch: true }); + const second = hook.installPreloadHook(electron, { + logger, + skipUpdaterWatch: true, + }); + assert.equal(second.wrapped, true); + const win = new electron.BrowserWindow({}); + assert.ok( + String(win.options.webPreferences.preload).endsWith( + 'preload-unlock.cjs' + ) + ); + }); +}); + +describe('hook: pop-out fallback', () => { + it('seeds windows that missed the preload via executeJavaScript', async () => { + const { electron, listeners } = makeFakeElectron(); + const { logger } = makeLogger(); + hook.installPreloadHook(electron, { logger, skipUpdaterWatch: true }); + assert.ok( + listeners['browser-window-created'], + 'pop-out listener registered' + ); + + const executed = []; + const fakeContents = { + once: (event, cb) => { + assert.equal(event, 'did-finish-load'); + fakeContents._loaded = cb; + }, + executeJavaScript: source => { + executed.push(source); + // First probe: not seeded. Second: the fallback source. + return Promise.resolve(executed.length > 1); + }, + }; + listeners['browser-window-created']({}, { webContents: fakeContents }); + fakeContents._loaded(); + await new Promise(resolve => setTimeout(resolve, 20)); + assert.equal(executed.length, 2); + assert.ok(executed[1].includes('__HERETEK__')); + assert.ok(executed[1].includes(USAGE_API_BASE)); + }); +}); + +describe('hook: webRequest fallback', () => { + it('registers a usage-scoped filter and spoofs the two GETs', () => { + const { electron, webRequests } = makeFakeElectron(); + const { logger } = makeLogger(); + const result = hook.installPreloadHook(electron, { + logger, + skipUpdaterWatch: true, + }); + assert.equal(result.requestWatched, true); + assert.deepEqual(webRequests[0].filter, { + urls: [`${USAGE_API_BASE}/*`], + }); + + const handler = webRequests[0].cb; + let redirected = null; + handler( + { url: `${USAGE_API_BASE}/limits?userId=x`, method: 'GET' }, + action => (redirected = action) + ); + assert.ok( + redirected.redirectURL.startsWith('data:application/json'), + 'limits redirected to data: URL' + ); + const limits = JSON.parse( + decodeURIComponent(redirected.redirectURL.split(',', 2)[1]) + ); + assert.equal(limits.capabilities.cloudProjects.maximumCount, 999); + + let sub = null; + handler({ url: `${USAGE_API_BASE}/subscription-v2`, method: 'GET' }, a => (sub = a)); + const subBody = JSON.parse( + decodeURIComponent(sub.redirectURL.split(',', 2)[1]) + ); + assert.equal(subBody.userId, 'local-byok-user'); + + let passthrough = null; + handler( + { url: `${USAGE_API_BASE}/generation`, method: 'POST' }, + a => (passthrough = a) + ); + assert.deepEqual(passthrough, {}); + }); +}); + +describe('hook: updater pin', () => { + afterEach(() => { + delete globalThis.__HERETEK__; + }); + + it('warns loudly on update-downloaded and flags the drop', () => { + const handlers = {}; + const { logger, records } = makeLogger(); + const watched = hook.watchUpdater( + { on: (event, cb) => (handlers[event] = cb) }, + logger + ); + assert.equal(watched, true); + handlers['update-downloaded'](); + assert.equal(globalThis.__HERETEK__.updatePending, true); + assert.equal(records.warn.length, 1); + assert.ok(records.warn[0].includes('apply-patches.mjs --asar')); + }); +}); diff --git a/tests/launcher/payloads.test.cjs b/tests/launcher/payloads.test.cjs new file mode 100644 index 0000000..a0f35d3 --- /dev/null +++ b/tests/launcher/payloads.test.cjs @@ -0,0 +1,78 @@ +// Payload contract tests (Phase 3): shapes satisfy the upstream +// validators (limits -> `capabilities`, subscription-v2 -> `userId`) +// and only the two Tier-1 GET endpoints are ever spoofed. +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { + USAGE_API_BASE, + UNLOCKED_SUBSCRIPTION, + UNLOCKED_CAPABILITIES, + buildSubscriptionPayload, + buildLimitsPayload, + isSpoofedUsageUrl, + spoofBodyForUrl, +} = require('../../launcher/unlock-payloads.cjs'); + +describe('payloads: unlock bodies', () => { + it('subscription payload carries the startup plan identity', () => { + const body = buildSubscriptionPayload(); + assert.equal(body.userId, 'local-byok-user'); + assert.equal(body.planId, 'gdevelop_startup'); + assert.equal(body.pricingSystemId, 'HERETEK_UNLOCKED'); + }); + + it('limits payload satisfies the upstream capabilities validator', () => { + const body = buildLimitsPayload(); + assert.ok(body.capabilities, 'ensureObjectHasProperty(..., capabilities)'); + assert.equal(body.capabilities.cloudProjects.maximumCount, 999); + assert.equal(body.capabilities.versionHistory.retentionDays, 365); + assert.deepEqual(body.quotas, {}); + assert.equal(body.credits.userBalance.amount, 999999); + }); + + it('builders default to the UNLOCKED_* single source', () => { + assert.deepEqual(buildSubscriptionPayload(), UNLOCKED_SUBSCRIPTION); + assert.deepEqual( + buildLimitsPayload().capabilities, + UNLOCKED_CAPABILITIES + ); + }); +}); + +describe('payloads: spoof surface', () => { + const limits = `${USAGE_API_BASE}/limits`; + const sub = `${USAGE_API_BASE}/subscription-v2`; + + it('spoofs exactly the two Tier-1 GET endpoints (query strings ok)', () => { + assert.equal(isSpoofedUsageUrl(limits, 'GET'), true); + assert.equal(isSpoofedUsageUrl(`${limits}?userId=abc`, 'GET'), true); + assert.equal(isSpoofedUsageUrl(sub, 'GET'), true); + assert.equal(isSpoofedUsageUrl(`${sub}?userId=abc`, 'GET'), true); + assert.equal(isSpoofedUsageUrl(limits, undefined), true); + }); + + it('passes through mutations, other endpoints, other hosts', () => { + assert.equal(isSpoofedUsageUrl(limits, 'POST'), false); + assert.equal(isSpoofedUsageUrl(sub, 'DELETE'), false); + assert.equal( + isSpoofedUsageUrl(`${USAGE_API_BASE}/generation`, 'GET'), + false + ); + assert.equal(isSpoofedUsageUrl(`${USAGE_API_BASE}/usage`, 'GET'), false); + assert.equal( + isSpoofedUsageUrl('https://evil.example.com/usage/limits', 'GET'), + false + ); + assert.equal(isSpoofedUsageUrl(null, 'GET'), false); + assert.equal(isSpoofedUsageUrl(`${USAGE_API_BASE}/limits-evil`, 'GET'), false); + }); + + it('spoofBodyForUrl returns validator-satisfying bodies', () => { + assert.ok(spoofBodyForUrl(limits, 'GET').capabilities); + assert.equal(spoofBodyForUrl(sub, 'GET').userId, 'local-byok-user'); + assert.equal( + spoofBodyForUrl(`${USAGE_API_BASE}/generation`, 'GET'), + null + ); + }); +}); diff --git a/tests/launcher/preload.test.cjs b/tests/launcher/preload.test.cjs new file mode 100644 index 0000000..5830e21 --- /dev/null +++ b/tests/launcher/preload.test.cjs @@ -0,0 +1,171 @@ +// Renderer hook tests (Phase 3): fetch/XHR spoofing on fakes, plus a +// vm-sandbox evaluation of getRendererFallbackSource() — the exact +// string heretek-hook.cjs injects into pop-out windows. If an embedded +// function closes over module scope, the sandbox run throws and this +// goes red. +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const vm = require('node:vm'); +const preload = require('../../launcher/preload-unlock.cjs'); +const { USAGE_API_BASE } = require('../../launcher/unlock-payloads.cjs'); + +const LIMITS = `${USAGE_API_BASE}/limits?userId=abc`; +const SUB = `${USAGE_API_BASE}/subscription-v2?userId=abc`; +const OTHER = `${USAGE_API_BASE}/generation`; + +class FakeResponse { + constructor(body, init) { + this._body = body; + this.status = init.status; + } + json() { + return Promise.resolve(JSON.parse(this._body)); + } +} + +function makeScope() { + const calls = []; + return { + scope: { + location: { href: 'https://editor.gdevelop.io/' }, + Response: FakeResponse, + setTimeout: (fn, ms) => setTimeout(fn, ms), + fetch: url => { + calls.push(url); + return Promise.resolve({ passthrough: true, url }); + }, + }, + calls, + }; +} + +function makeXhrClass() { + return class FakeXHR { + constructor() { + this.readyState = 0; + this.status = 0; + this.responseText = ''; + this.response = ''; + this.onreadystatechange = null; + this.onload = null; + this.sent = []; + } + open(method, url) { + this.method = method; + this.url = url; + } + send() { + this.sent.push(this.url); + } + }; +} + +describe('preload: fetch wrapper', () => { + it('spoofs limits + subscription GETs, logs them, passes the rest through', async () => { + const { scope, calls } = makeScope(); + preload.installRendererHook(scope); + assert.equal(scope.__HERETEK__.unlocked, true); + assert.equal(scope.__HERETEK__.planId, 'gdevelop_startup'); + + const limitsRes = await scope.fetch(LIMITS, { method: 'GET' }); + assert.equal(limitsRes.status, 200); + const limits = await limitsRes.json(); + assert.equal(limits.capabilities.cloudProjects.maximumCount, 999); + + const subRes = await scope.fetch(SUB); + assert.equal((await subRes.json()).userId, 'local-byok-user'); + + const other = await scope.fetch(OTHER); + assert.equal(other.passthrough, true); + assert.deepEqual(calls, [OTHER]); + + const post = await scope.fetch(LIMITS, { method: 'POST' }); + assert.equal(post.passthrough, true); + assert.deepEqual(scope.__HERETEK__.spoofedRequests, [LIMITS, SUB]); + }); +}); + +describe('preload: XHR patch', () => { + it('spoofs usage GETs asynchronously, delegates the rest', async () => { + const { scope } = makeScope(); + const XHR = makeXhrClass(); + preload.patchXhr(XHR, scope); + + const xhr = new XHR(); + let loaded = null; + xhr.onload = () => { + loaded = { status: xhr.status, body: JSON.parse(xhr.responseText) }; + }; + xhr.open('GET', LIMITS); + xhr.send(); + await new Promise(resolve => setTimeout(resolve, 20)); + assert.ok(loaded, 'spoofed onload never fired'); + assert.equal(loaded.status, 200); + assert.equal(loaded.body.capabilities.versionHistory.retentionDays, 365); + + const other = new XHR(); + other.open('POST', LIMITS); + other.send(); + await new Promise(resolve => setTimeout(resolve, 20)); + assert.deepEqual(other.sent, [LIMITS]); + }); +}); + +describe('preload: fallback source (vm sandbox)', () => { + it('embeds every referenced name (no module-scope leaks)', () => { + const payloads = require('../../launcher/unlock-payloads.cjs'); + const namespace = Object.assign({}, payloads, preload); + for (const name of preload.FALLBACK_EMBEDDED_FUNCTIONS) { + assert.equal( + typeof namespace[name], + 'function', + `fallback embeds missing function: ${name}` + ); + } + for (const name of preload.FALLBACK_EMBEDDED_CONSTS) { + assert.ok( + payloads[name] !== undefined, + `fallback embeds missing const: ${name}` + ); + } + }); + + it('evaluates standalone and spoofs in the sandbox', async () => { + const source = preload.getRendererFallbackSource(); + assert.ok(source.includes('__HERETEK__')); + assert.ok(source.includes(USAGE_API_BASE)); + const { scope } = makeScope(); + scope.XMLHttpRequest = makeXhrClass(); + const sandbox = { + window: scope, + URL, + Promise, + JSON, + Object, + setTimeout, + }; + vm.createContext(sandbox); + vm.runInContext(source, sandbox); + assert.equal(sandbox.window.__HERETEK__.unlocked, true); + + const res = await sandbox.window.fetch(LIMITS); + assert.equal(res.status, 200); + assert.equal( + (await res.json()).capabilities.leaderboards.maximumCountPerGame, + 999 + ); + const passthrough = await sandbox.window.fetch(OTHER); + assert.equal(passthrough.passthrough, true); + + const XHR = sandbox.window.XMLHttpRequest; + const xhr = new XHR(); + const loaded = await new Promise(resolve => { + xhr.onload = () => + resolve({ status: xhr.status, body: JSON.parse(xhr.response) }); + xhr.open('GET', SUB); + xhr.send(); + }); + assert.equal(loaded.status, 200); + assert.equal(loaded.body.planId, 'gdevelop_startup'); + }); +}); diff --git a/tests/patches/apply.test.mjs b/tests/patches/apply.test.mjs new file mode 100644 index 0000000..9a74c35 --- /dev/null +++ b/tests/patches/apply.test.mjs @@ -0,0 +1,118 @@ +// Phase 2 tests: the Tier-1 patcher applies cleanly to a pristine +// upstream tree, produces the unlock markers, and refuses a second run +// (non-idempotent by design — a green re-apply would mean the hunks +// never matched in the first place). +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(here, '..', '..'); +const UPSTREAM = process.env.UPSTREAM_DIR || path.join(ROOT, 'review', 'GDevelop'); + +const TIER1_FILES = [ + 'newIDE/app/src/Utils/GDevelopServices/Usage.js', + 'newIDE/app/src/Profile/AuthenticatedUserContext.js', + 'newIDE/app/src/Profile/Subscription/SubscriptionChecker.js', + 'newIDE/app/src/ProjectManager/LoadingScreenEditor.js', + 'newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/index.js', + 'newIDE/app/src/MainFrame/EditorContainers/DebuggerEditorContainer.js', + // Patch 06 (loader line) needs its anchor file too. + 'newIDE/electron-app/app/main.js', + // Tier-2 glue targets (07/08; the fork-only Storage.js needs no target). + 'newIDE/app/src/Utils/GDevelopServices/Generation.js', + 'newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js', + 'newIDE/app/src/MainFrame/Preferences/PreferencesContext.js', + 'newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js', +]; + +describe('patcher: Tier-1 hunks apply to pristine upstream', () => { + let target; + before(() => { + target = fs.mkdtempSync(path.join(os.tmpdir(), 'tier1-patch-test-')); + for (const rel of TIER1_FILES) { + const src = path.join(UPSTREAM, rel); + assert.ok(fs.existsSync(src), `upstream file missing: ${rel}`); + const dest = path.join(target, rel); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(src, dest); + } + }); + after(() => fs.rmSync(target, { recursive: true, force: true })); + + it('dry-run is clean on the pristine tree', () => { + const r = spawnSync( + 'node', + ['scripts/apply-patches.mjs', '--target', target, '--no-verify'], + { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' } + ); + assert.equal( + r.status, + 0, + `patcher failed on pristine tree:\n${r.stdout}\n${r.stderr}` + ); + }); + + it('patched tree carries the unlock markers and no gates', () => { + const read = rel => fs.readFileSync(path.join(target, rel), 'utf8'); + const usage = read(TIER1_FILES[0]); + assert.ok(usage.includes("planId: 'gdevelop_startup'")); + assert.ok( + usage.includes( + '// In GDevelop-Heretek, all client-side and offline features are unlocked for all users.\n return true;' + ) + ); + assert.ok(!usage.includes('redemptionCodeValidUntil > Date.now()')); + + const auth = read(TIER1_FILES[1]); + assert.ok(auth.includes('subscription: UNLOCKED_HERETEK_SUBSCRIPTION,')); + assert.ok(!auth.includes('subscription: null,')); + + const checker = read(TIER1_FILES[2]); + assert.ok( + checker.includes( + 'const hasUserSubscription = () => {\n return true;' + ) + ); + assert.ok(!checker.includes('hasValidSubscriptionPlan')); + + const loading = read(TIER1_FILES[3]); + assert.ok( + loading.includes( + 'watermark.showGDevelopWatermark(checked);\n onUpdate();' + ) + ); + assert.ok(!loading.includes('setMinDuration(forcedLogo.minDuration)')); + + const preview = read(TIER1_FILES[4]); + assert.ok( + preview.includes( + ' _checkSubscriptionForNetworkPreview = (): any => {\n return true;' + ) + ); + assert.ok(!preview.includes('hotReloadsCount % 16')); + + const dbg = read(TIER1_FILES[5]); + assert.ok(dbg.includes('// Unlocked in GDevelop-Heretek')); + assert.ok( + !dbg.includes('this._subscriptionChecker.checkUserHasSubscription()') + ); + }); + + it('second apply fails (hunks already consumed)', () => { + const r = spawnSync( + 'node', + ['scripts/apply-patches.mjs', '--target', target, '--no-verify'], + { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' } + ); + assert.notEqual( + r.status, + 0, + 're-apply succeeded — hunks may not be matching (silent no-op risk)' + ); + }); +}); diff --git a/tests/seams/debugger.seam.test.mjs b/tests/seams/debugger.seam.test.mjs new file mode 100644 index 0000000..c666173 --- /dev/null +++ b/tests/seams/debugger.seam.test.mjs @@ -0,0 +1,55 @@ +// Phase 1 seam: in-game debugger gate. +// Audit: `MainFrame/EditorContainers/DebuggerEditorContainer.js:135-146`. +import { describe, it } from 'node:test'; +import { + readUpstream, + mustContain, + mustMatchWord, + mustBeNearLine, +} from './helpers.mjs'; + +const REL = + 'newIDE/app/src/MainFrame/EditorContainers/DebuggerEditorContainer.js'; + +describe('seam: DebuggerEditorContainer gate', () => { + const file = readUpstream(REL); + + it('keeps the _checkUserHasSubscription guard', () => { + mustMatchWord(file, '_checkUserHasSubscription', { + what: 'debugger paywall entry point', + hint: 'Phase 2 hunk 5 stubs the body', + }); + }); + + it('guard sits at its audited hunk (~line 135, tolerance ±30)', () => { + mustBeNearLine( + file, + /_checkUserHasSubscription\(\) \{/, + 135, + 30, + '_checkUserHasSubscription' + ); + }); + + it('guard shape: checker + isActive + one-shot flag', () => { + for (const token of [ + 'this._subscriptionChecker', + 'this.props.isActive', + 'subscriptionChecked', + ]) { + mustContain(file, token, { what: 'guard condition' }); + } + mustMatchWord(file, 'checkUserHasSubscription', { + what: 'checker invocation inside the guard', + }); + }); + + it('debugger tab mounts a SubscriptionChecker with id="Debugger"', () => { + mustContain(file, 'id="Debugger"', { + what: 'checker placement id for the debugger tab', + }); + mustContain(file, 'placementId="debugger"', { + what: 'analytics placement id', + }); + }); +}); diff --git a/tests/seams/electron.seam.test.mjs b/tests/seams/electron.seam.test.mjs new file mode 100644 index 0000000..e863df5 --- /dev/null +++ b/tests/seams/electron.seam.test.mjs @@ -0,0 +1,138 @@ +// Phase 1 seam: Electron hookability + absence of plugin architecture. +// Audit: `electron-app/app/main.js:174-187` (webPreferences), +// `Utils/UrlLoader.js:5-53` (gdide:// + file://), `main.js:361` +// (did-create-window), PreviewWindow.js, zero preload files. +// Also freezes the "no IDE plugin registry" verdict: `newIDE/app/src` +// must contain zero plugin-registry matches. +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { UPSTREAM_DIR, readUpstream, mustContain, mustMatchWord, mustBeNearLine } from './helpers.mjs'; + +const MAIN = 'newIDE/electron-app/app/main.js'; +const URL_LOADER = 'newIDE/electron-app/app/Utils/UrlLoader.js'; + +function walkJsFiles(dir, out = []) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'node_modules') continue; + walkJsFiles(abs, out); + } else if (/\.m?js$/.test(entry.name)) { + out.push(abs); + } + } + return out; +} + +describe('seam: Electron launcher hook surface', () => { + const main = readUpstream(MAIN); + const urlLoader = readUpstream(URL_LOADER); + + it('renderer stays hookable: nodeIntegration + no isolation + no webSecurity', () => { + mustContain(main, 'nodeIntegration: true', { + what: 'renderer Node access (Vector A precondition)', + }); + mustContain(main, 'contextIsolation: false', { + what: 'shared-global precondition for preload seed', + }); + mustContain(main, 'webSecurity: false', { + what: 'local-file access', + }); + }); + + it('covers window-creation funnels the hook must attach to', () => { + mustMatchWord(main, 'createNewWindow', { + what: 'single funnel Phase 3 hooks (~10 lines)', + }); + mustContain(main, 'did-create-window', { + what: 'portal pop-out coverage (main.js:361)', + }); + mustContain(main, 'PreviewWindow', { + what: 'preview-window coverage', + }); + }); + + it('main.js require anchor is stable (06 loader insertion point)', () => { + // Patch 06 inserts the hook loader BEFORE this line: the wrap must + // precede the `electron.BrowserWindow` destructure (main.js:6) or the + // already-captured references (main.js:226, PreviewWindow.js:52) + // escape it. + mustBeNearLine( + main, + /^const electron = require\('electron'\);/, + 1, + 3, + 'electron require anchor' + ); + mustContain(main, 'const newWindow = new BrowserWindow(options);', { + what: 'wrapped-constructor call site', + }); + }); + + it('preview windows flow through the wrapped constructor', () => { + const preview = readUpstream('newIDE/electron-app/app/PreviewWindow.js'); + mustContain(preview, 'new BrowserWindow(browserWindowOptions)', { + what: 'preview constructor the hook wraps', + }); + }); + + it('UrlLoader keeps the gdide:// + file:// loading paths', () => { + mustContain(urlLoader, 'registerGdideProtocol', { + what: 'custom-scheme loader', + }); + mustContain(urlLoader, 'registerBufferProtocol', { + what: 'production gdide:// path', + }); + mustContain(urlLoader, 'file://', { what: 'production file load' }); + }); + + it('documents the auto-updater pin (hook must survive updates)', () => { + mustContain(main, 'autoUpdater', { + what: 'updater path Phase 3 must pin/document', + }); + }); + + it('flags any new preload file (hook-attachment point changed)', () => { + const appDir = path.join(UPSTREAM_DIR, 'newIDE/electron-app/app'); + const withPreloadOption = walkJsFiles(appDir).filter(abs => + /preload\s*:/.test(fs.readFileSync(abs, 'utf8')) + ); + assert.deepEqual( + withPreloadOption, + [], + `SEAM DRIFT: upstream added a preload option: ${withPreloadOption.join(', ')}. ` + + `Re-evaluate the Phase 3 attachment (preload-unlock.cjs) against the new loader.` + ); + }); +}); + +describe('seam: no IDE plugin registry (verdict freeze)', () => { + it('newIDE/app/src contains zero plugin-registry seams', () => { + const srcDir = path.join(UPSTREAM_DIR, 'newIDE/app/src'); + const hits = walkJsFiles(srcDir) + .filter(abs => + /registerIDEPlugin|pluginRegistry|registerPlugin|extensionPoint/i.test( + fs.readFileSync(abs, 'utf8') + ) + ) + .map(abs => path.relative(UPSTREAM_DIR, abs)); + assert.deepEqual( + hits, + [], + `SEAM DRIFT: a plugin-registry-like seam appeared: ${hits.join(', ')}. ` + + `Re-evaluate the no-plugin-architecture verdict before building Tier-2 as a package.` + ); + }); + + it('webpack chunk-splitting convention still exists (Vector D tap)', () => { + const index = readUpstream('newIDE/app/src/index.js'); + mustContain(index, 'webpackChunkName: "local-app"', { + what: 'CRA chunk convention the userscript taps', + }); + mustContain(index, 'webpackChunkName: "browser-app"', { + what: 'web-editor chunk the userscript taps', + }); + }); +}); diff --git a/tests/seams/helpers.mjs b/tests/seams/helpers.mjs new file mode 100644 index 0000000..f4a5205 --- /dev/null +++ b/tests/seams/helpers.mjs @@ -0,0 +1,108 @@ +// Shared helpers for Phase 1 seam (drift-tripwire) tests. +// Each assertion failure prints a `file:line` pointer so upstream drift +// is loud, not silent. Shape assertions only — never behavior. +import fs from 'node:fs'; +import path from 'node:path'; +import assert from 'node:assert/strict'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +// Repo root = two levels above tests/seams/. +export const REPO_ROOT = path.resolve(here, '..', '..'); +export const UPSTREAM_DIR = + process.env.UPSTREAM_DIR || path.join(REPO_ROOT, 'review', 'GDevelop'); + +export function readUpstream(relPath) { + const abs = path.join(UPSTREAM_DIR, relPath); + assert.ok( + fs.existsSync(abs), + `Missing upstream file: ${relPath} (looked in ${abs}). ` + + `Set UPSTREAM_DIR or refresh review/GDevelop.` + ); + return { abs, rel: relPath, text: fs.readFileSync(abs, 'utf8') }; +} + +export function lines(text) { + return text.split('\n'); +} + +/** 1-based line number of the first line matching `re`, or -1. */ +export function findLine(text, re) { + const ls = lines(text); + for (let i = 0; i < ls.length; i++) { + if (re.test(ls[i])) return i + 1; + } + return -1; +} + +/** All 1-based line numbers matching `re`. */ +export function findAllLines(text, re) { + const out = []; + const ls = lines(text); + for (let i = 0; i < ls.length; i++) { + if (re.test(ls[i])) out.push(i + 1); + } + return out; +} + +export function mustContain( + { abs, rel, text }, + substring, + { what, hint } = {} +) { + assert.ok( + text.includes(substring), + `SEAM DRIFT: ${rel} no longer contains ${JSON.stringify(substring)}` + + `${what ? ` (${what})` : ''}. File: ${abs}.` + + `${hint ? ` Hint: ${hint}` : ''}` + ); +} + +export function mustMatch({ abs, rel, text }, re, { what, hint } = {}) { + assert.ok( + re.test(text), + `SEAM DRIFT: ${rel} no longer matches ${re}` + + `${what ? ` (${what})` : ''}. File: ${abs}.` + + `${hint ? ` Hint: ${hint}` : ''}` + ); +} + +export function mustMatchWord(file, symbol, { what, hint } = {}) { + const { abs, rel, text } = file; + const escaped = symbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // Block prefix-collisions: a rename to `${symbol}_RENAMED` must NOT match. + const re = new RegExp(escaped + '(?![\\w$])'); + assert.ok( + re.test(text), + `SEAM DRIFT: ${rel} lost symbol ${JSON.stringify(symbol)}` + + `${what ? ` (${what})` : ''}. File: ${abs}.` + + `${hint ? ` Hint: ${hint}` : ''}` + ); +} + +/** + * Assert a seam symbol lives near its audited line (tolerance guards + * against hunk shifts; a move beyond tolerance fails loudly). + */ +export function mustBeNearLine( + file, + re, + expectedLine, + tolerance, + symbolName +) { + const actual = findLine(file.text, re); + assert.ok( + actual !== -1, + `SEAM DRIFT: ${file.rel} lost symbol ${symbolName} (${re}). ` + + `File: ${file.abs}. Expected near line ${expectedLine}.` + ); + const drift = Math.abs(actual - expectedLine); + assert.ok( + drift <= tolerance, + `SEAM DRIFT: ${symbolName} moved in ${file.rel} ` + + `from ~line ${expectedLine} to line ${actual} (drift ${drift} > tolerance ${tolerance}). ` + + `File: ${file.abs}:${actual}. Update patches/ + launcher to the new hunk.` + ); + return actual; +} diff --git a/tests/seams/loadingscreen.seam.test.mjs b/tests/seams/loadingscreen.seam.test.mjs new file mode 100644 index 0000000..075958a --- /dev/null +++ b/tests/seams/loadingscreen.seam.test.mjs @@ -0,0 +1,76 @@ +// Phase 1 seam: branding/clamp gates (Phase 2 hunk 4 targets). +// Audit: `ProjectManager/LoadingScreenEditor.js` — logo guard (~`:106-117`), +// watermark guard + forced-duration clamp (~`:162-213`). +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + readUpstream, + mustContain, + mustMatchWord, + mustBeNearLine, + findAllLines, +} from './helpers.mjs'; + +const REL = 'newIDE/app/src/ProjectManager/LoadingScreenEditor.js'; + +describe('seam: LoadingScreenEditor branding gates', () => { + const file = readUpstream(REL); + + it('keeps the startup-logo paywall guard', () => { + mustMatchWord(file, 'isGDevelopLogoShownDuringLoadingScreen', { + what: 'logo-branding predicate', + }); + mustBeNearLine( + file, + /checked=\{loadingScreen\.isGDevelopLogoShownDuringLoadingScreen/, + 106, + 40, + 'logo checkbox guard' + ); + }); + + it('keeps the watermark paywall guard + clamp', () => { + mustMatchWord(file, 'isGDevelopWatermarkShown', { + what: 'watermark predicate', + }); + mustBeNearLine( + file, + /checked=\{watermark\.isGDevelopWatermarkShown/, + 162, + 40, + 'watermark checkbox guard' + ); + mustContain(file, 'forcedLogo.minDuration', { + what: 'forced splash-duration clamp', + }); + mustContain(file, 'setMinDuration(forcedLogo.minDuration)', { + what: 'clamp write-back on watermark disable', + }); + }); + + it('both guards consult the subscription checker', () => { + mustMatchWord(file, 'subscriptionChecker', { + what: 'checker ref in branding panel', + }); + mustMatchWord(file, 'checkUserHasSubscription', { + what: 'dialog path when unchecking without subscription', + }); + mustMatchWord(file, 'hasUserSubscription', { + what: 'silent path for the duration clamp', + }); + }); + + it('predicates are referenced pervasively (not dead code)', () => { + const logoHits = findAllLines( + file.text, + /isGDevelopLogoShownDuringLoadingScreen/ + ).length; + const watermarkHits = findAllLines(file.text, /isGDevelopWatermarkShown/) + .length; + assert.ok( + logoHits >= 3 && watermarkHits >= 3, + `SEAM DRIFT: branding predicates look dead (logo x${logoHits}, watermark x${watermarkHits}). ` + + `File: ${file.abs}. Expected >= 3 references each.` + ); + }); +}); diff --git a/tests/seams/preview.seam.test.mjs b/tests/seams/preview.seam.test.mjs new file mode 100644 index 0000000..095bfdb --- /dev/null +++ b/tests/seams/preview.seam.test.mjs @@ -0,0 +1,56 @@ +// Phase 1 seam: hot-reload + network-preview gates. +// Audit: `LocalPreviewLauncher/index.js:439-448` (`% 16` block) and +// `:489-493` (`_checkSubscriptionForNetworkPreview`). +import { describe, it } from 'node:test'; +import { + readUpstream, + mustContain, + mustMatchWord, + mustBeNearLine, +} from './helpers.mjs'; + +const REL = + 'newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/index.js'; + +describe('seam: LocalPreviewLauncher gates', () => { + const file = readUpstream(REL); + + it('keeps the % 16 hot-reload paywall block', () => { + mustContain(file, 'hotReloadsCount % 16 === 0', { + what: 'hot-reload paywall cadence', + hint: 'Phase 2 hunk 5 deletes this block', + }); + mustMatchWord(file, '_hotReloadSubscriptionChecker', { + what: 'checker ref driving the % 16 popup', + }); + mustMatchWord(file, 'checkUserHasSubscription', { + what: 'checker call inside the % 16 block', + }); + }); + + it('% 16 block sits at its audited hunk (~line 441, tolerance ±40)', () => { + mustBeNearLine( + file, + /hotReloadsCount % 16 === 0/, + 441, + 40, + '% 16 hot-reload gate' + ); + }); + + it('keeps the network-preview subscription stub point', () => { + mustMatchWord(file, '_checkSubscriptionForNetworkPreview', { + what: 'wifi/LAN preview gate', + hint: 'Phase 2 hunk 5 stubs this to `return true`', + }); + mustMatchWord(file, '_networkPreviewSubscriptionChecker', { + what: 'network preview checker ref', + }); + }); + + it('increments hotReloadsCount after the gate', () => { + mustContain(file, 'hotReloadsCount: state.hotReloadsCount + 1', { + what: 'counter increment following the gate', + }); + }); +}); diff --git a/tests/seams/subscription-checker.seam.test.mjs b/tests/seams/subscription-checker.seam.test.mjs new file mode 100644 index 0000000..aa9c60d --- /dev/null +++ b/tests/seams/subscription-checker.seam.test.mjs @@ -0,0 +1,59 @@ +// Phase 1 seam: subscription dialog gate. +// Audit: `Profile/Subscription/SubscriptionChecker.js:66-87` — both +// closures funnel through `hasValidSubscriptionPlan`. +import { describe, it } from 'node:test'; +import { + readUpstream, + mustContain, + mustBeNearLine, +} from './helpers.mjs'; + +const REL = 'newIDE/app/src/Profile/Subscription/SubscriptionChecker.js'; + +describe('seam: SubscriptionChecker dialog gate', () => { + const file = readUpstream(REL); + + it('exposes the checker interface shape', () => { + mustContain(file, 'checkUserHasSubscription: () => boolean', { + what: 'SubscriptionCheckerInterface.checkUserHasSubscription', + }); + mustContain(file, 'hasUserSubscription: () => boolean', { + what: 'SubscriptionCheckerInterface.hasUserSubscription', + }); + }); + + it('checkUserHasSubscription sits at its audited hunk (~line 66, tolerance ±40)', () => { + mustBeNearLine( + file, + /const checkUserHasSubscription(?![\w$])/, + 66, + 40, + 'checkUserHasSubscription' + ); + }); + + it('both closures consult hasValidSubscriptionPlan', () => { + mustContain( + file, + 'hasValidSubscriptionPlan(authenticatedUser.subscription)', + { what: 'funnel shared by both closures' } + ); + mustBeNearLine( + file, + /const hasUserSubscription(?![\w$])/, + 85, + 40, + 'hasUserSubscription' + ); + }); + + it('denial opens the paywall dialog', () => { + mustContain(file, 'setDialogOpen(true)', { + what: 'dialog-open on failed check', + hint: 'Phase 2 hunk 3 stubs both closures to `return true` so this never fires', + }); + mustContain(file, 'sendSubscriptionCheckDialogShown', { + what: 'analytics ping on dialog show', + }); + }); +}); diff --git a/tests/seams/usage.seam.test.mjs b/tests/seams/usage.seam.test.mjs new file mode 100644 index 0000000..38c21e3 --- /dev/null +++ b/tests/seams/usage.seam.test.mjs @@ -0,0 +1,61 @@ +// Phase 1 seam: Tier-1 entitlement funnel. +// Audit: `newIDE/app/src/Utils/GDevelopServices/Usage.js:605-620` — +// every paywall funnels through `hasValidSubscriptionPlan`. +// Shape asserted here; Phase 2 hunk 1 rewrites the body to `return true`. +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + readUpstream, + mustContain, + mustMatchWord, + mustBeNearLine, +} from './helpers.mjs'; + +const REL = 'newIDE/app/src/Utils/GDevelopServices/Usage.js'; + +describe('seam: Usage.hasValidSubscriptionPlan', () => { + const file = readUpstream(REL); + + it('exports the hasValidSubscriptionPlan predicate', () => { + // Word-boundary match: a rename (even to a superstring) must trip. + mustMatchWord(file, 'export const hasValidSubscriptionPlan', { + what: 'Tier-1 funnel predicate', + hint: 'Phase 2 hunk 1 + preload Module._load intercept both target this export', + }); + }); + + it('sits at its audited hunk (~line 605, tolerance ±40)', () => { + mustBeNearLine( + file, + /export const hasValidSubscriptionPlan(?![\w$])/, + 605, + 40, + 'hasValidSubscriptionPlan' + ); + }); + + it('keeps the planId + redemption-code gate shape', () => { + for (const token of [ + 'subscription.planId', + 'redemptionCodeValidUntil', + 'Date.now()', + ]) { + mustContain(file, token, { + what: 'gate condition inside hasValidSubscriptionPlan', + }); + } + mustContain(file, 'return true', { what: 'valid-subscription branch' }); + mustContain(file, 'return false', { what: 'default-deny branch' }); + }); + + it('predicate takes a Subscription and returns boolean', () => { + const idx = file.text.indexOf('export const hasValidSubscriptionPlan'); + assert.ok(idx !== -1, `SEAM DRIFT: predicate missing. File: ${file.abs}`); + const window = file.text.slice(idx, idx + 600); + assert.ok( + window.includes('Subscription') && window.includes('boolean'), + `SEAM DRIFT: predicate signature drifted (expected (subscription: ?Subscription) => boolean). ` + + `File: ${file.abs}. Slice: ${JSON.stringify(window.slice(0, 200))}` + ); + }); +}); diff --git a/tests/tier2/extract.test.mjs b/tests/tier2/extract.test.mjs new file mode 100644 index 0000000..8b323bc --- /dev/null +++ b/tests/tier2/extract.test.mjs @@ -0,0 +1,99 @@ +// Tier-2 extraction tests (Phase 4): packages/ai-studio/ stays +// byte-identical to the fork donor, and the extracted client keeps the +// BYOK invariant (no hosted generation references). +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(here, '..', '..'); +const PACKAGE = path.join(ROOT, 'packages', 'ai-studio'); +const FORK = + process.env.FORK_DIR || path.join(ROOT, 'review', 'GDevelop-Heretek'); + +function sha256(abs) { + return crypto.createHash('sha256').update(fs.readFileSync(abs)).digest('hex'); +} + +describe('tier-2: verbatim extraction', () => { + const manifest = JSON.parse( + fs.readFileSync(path.join(PACKAGE, 'MANIFEST.json'), 'utf8') + ); + + it('every manifest file matches the donor byte-for-byte', () => { + assert.ok( + Object.keys(manifest.files).length >= 19, + 'manifest looks truncated' + ); + for (const [pkgRel, meta] of Object.entries(manifest.files)) { + const pkgAbs = path.join(PACKAGE, pkgRel); + const donorAbs = path.join(FORK, meta.donor); + assert.ok(fs.existsSync(pkgAbs), `package file missing: ${pkgRel}`); + assert.ok( + fs.existsSync(donorAbs), + `donor moved in fork: ${meta.donor} (update scripts/extract-tier2.mjs explicitly)` + ); + assert.equal( + sha256(pkgAbs), + sha256(donorAbs), + `drift vs donor: ${pkgRel}` + ); + assert.equal( + sha256(pkgAbs), + meta.sha256, + `manifest stale for ${pkgRel} (re-run the extractor)` + ); + } + }); + + it('CustomAIClient has zero hosted-generation references', () => { + const text = fs.readFileSync( + path.join(PACKAGE, 'AI', 'CustomAIClient.js'), + 'utf8' + ); + assert.ok( + !text.includes('api.gdevelop.io'), + 'BYOK client must not hardcode the hosted API' + ); + assert.ok( + text.includes('GDEVELOP_OPENAI_TOOLS'), + 'tool schema missing from extraction' + ); + assert.ok( + text.includes('gd-custom-ai-config'), + 'localStorage config key missing from extraction' + ); + }); + + it('all 12 Studio prod modules extracted (no silent drops)', () => { + const expected = [ + 'EditApprovalLabel', + 'FinalizeSubAgents', + 'LoopGuard', + 'NudgePolicy', + 'PlanStore', + 'RequestWriteGate', + 'Roles', + 'RoleToolPolicy', + 'SafeTruncation', + 'SpawnSubAgents', + 'UseStudioNudge', + 'UseStudioRuntime', + ]; + for (const name of expected) { + assert.ok( + fs.existsSync(path.join(PACKAGE, 'AiGeneration', 'Studio', `${name}.js`)), + `Studio module missing: ${name}` + ); + } + }); + + it('offline E2E harness extracted', () => { + assert.ok( + fs.existsSync(path.join(PACKAGE, 'dev-harness', 'mock-ai-provider.js')) + ); + }); +}); diff --git a/tests/tier2/shim.test.mjs b/tests/tier2/shim.test.mjs new file mode 100644 index 0000000..fa42ed3 --- /dev/null +++ b/tests/tier2/shim.test.mjs @@ -0,0 +1,98 @@ +// Generation shim tests (Phase 4): routing predicates + the +// fail-closed leak guard. These invariants are what stop `local-ai-*` +// ids from ever reaching the hosted generation API. +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + isLocalAiRequestId, + partitionAiRequestIds, + isCustomEndpointEnabled, + selectBackend, + assertNoLocalIdsLeak, + guardHostedCall, +} from '../../packages/unlock-shim/generation-shim.mjs'; + +const CUSTOM = { enabled: true, baseUrl: 'http://localhost:11434/v1' }; + +describe('shim: id classification', () => { + it('recognizes local-ai-* ids exactly', () => { + assert.equal(isLocalAiRequestId('local-ai-abc123'), true); + assert.equal(isLocalAiRequestId('hosted-uuid'), false); + assert.equal(isLocalAiRequestId('LOCAL-AI-x'), false); + assert.equal(isLocalAiRequestId(null), false); + assert.equal(isLocalAiRequestId(undefined), false); + }); + + it('partitions mixed batches (fork 07 split)', () => { + assert.deepEqual( + partitionAiRequestIds(['local-ai-1', 'h-1', 'local-ai-2', 'h-2']), + { localIds: ['local-ai-1', 'local-ai-2'], hostedIds: ['h-1', 'h-2'] } + ); + assert.deepEqual(partitionAiRequestIds([]), { localIds: [], hostedIds: [] }); + }); +}); + +describe('shim: backend selection', () => { + it('local ids win over everything (no hosted representation)', () => { + assert.equal( + selectBackend({ aiRequestId: 'local-ai-1', customConfig: null }), + 'custom' + ); + assert.equal( + selectBackend({ aiRequestIds: ['h-1', 'local-ai-2'], customConfig: null }), + 'custom' + ); + }); + + it('enabled endpoint routes hosted-shaped traffic to custom', () => { + assert.equal( + selectBackend({ aiRequestId: 'h-1', customConfig: CUSTOM }), + 'custom' + ); + }); + + it('disabled/absent config stays hosted', () => { + assert.equal( + selectBackend({ aiRequestId: 'h-1', customConfig: null }), + 'hosted' + ); + assert.equal( + selectBackend({ + aiRequestId: 'h-1', + customConfig: { enabled: false, baseUrl: 'http://x/v1' }, + }), + 'hosted' + ); + assert.equal( + selectBackend({ + aiRequestId: 'h-1', + customConfig: { enabled: true, baseUrl: '' }, + }), + 'hosted' + ); + }); +}); + +describe('shim: leak guard (fail-closed)', () => { + it('throws on local ids aimed at hosted endpoints', () => { + assert.throws( + () => assertNoLocalIdsLeak(['h-1', 'local-ai-9'], 'POST /generation'), + /refusing hosted call/ + ); + assert.doesNotThrow(() => assertNoLocalIdsLeak(['h-1'], 'POST /generation')); + assert.doesNotThrow(() => assertNoLocalIdsLeak([], 'POST /generation')); + }); + + it('guardHostedCall blocks before the hosted fn runs', () => { + let ran = false; + const guarded = guardHostedCall( + () => (ran = true), + 'POST /generation', + (...args) => args[0] + ); + guarded(['h-1']); + assert.equal(ran, true); + assert.throws(() => guarded(['local-ai-1']), /refusing hosted call/); + assert.equal(ran, true); + }); +}); diff --git a/tests/tier2/tier2-patches.test.mjs b/tests/tier2/tier2-patches.test.mjs new file mode 100644 index 0000000..70159c9 --- /dev/null +++ b/tests/tier2/tier2-patches.test.mjs @@ -0,0 +1,103 @@ +// Tier-2 glue patch tests (Phase 4): committed 07/08 patches equal +// fresh generator output (deterministic — any hand-edit or fork drift +// goes red), and dry-run clean on pristine upstream copies. +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(here, '..', '..'); +const UPSTREAM = + process.env.UPSTREAM_DIR || path.join(ROOT, 'review', 'GDevelop'); +const FORK = + process.env.FORK_DIR || path.join(ROOT, 'review', 'GDevelop-Heretek'); + +const GLUE = [ + { + patch: '07-generation-glue.patch', + files: ['newIDE/app/src/Utils/GDevelopServices/Generation.js'], + }, + { + patch: '08-preferences-ai.patch', + files: [ + 'newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js', + 'newIDE/app/src/MainFrame/Preferences/PreferencesContext.js', + 'newIDE/app/src/MainFrame/Preferences/PreferencesProvider.js', + 'newIDE/app/src/MainFrame/Preferences/PreferencesStorage.js', + ], + }, +]; + +function run(cmd, args, cwd) { + return spawnSync(cmd, args, { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf8', + }); +} + +describe('tier-2: glue patches are generated, not hand-edited', () => { + let regenDir; + before(() => { + regenDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tier2-regen-')); + const r = run( + 'node', + [ + 'scripts/generate-tier2-patches.mjs', + '--upstream', + UPSTREAM, + '--fork', + FORK, + '--out', + regenDir, + ], + ROOT + ); + assert.equal(r.status, 0, `generator failed:\n${r.stdout}\n${r.stderr}`); + }); + after(() => fs.rmSync(regenDir, { recursive: true, force: true })); + + it('committed patches equal fresh output byte-for-byte', () => { + for (const { patch } of GLUE) { + const committed = fs.readFileSync( + path.join(ROOT, 'patches', patch), + 'utf8' + ); + const fresh = fs.readFileSync(path.join(regenDir, patch), 'utf8'); + assert.equal(fresh, committed, `${patch} was hand-edited or is stale`); + } + }); + + it('glue dry-runs clean on pristine upstream copies', () => { + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'tier2-dry-')); + try { + for (const { files } of GLUE) { + for (const rel of files) { + const src = path.join(UPSTREAM, rel); + if (!fs.existsSync(src)) continue; // new-file hunks need no target + const dest = path.join(target, rel); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(src, dest); + } + } + for (const { patch } of GLUE) { + const r = run( + 'patch', + ['-p1', '--dry-run', '-i', path.join(ROOT, 'patches', patch)], + target + ); + assert.equal( + r.status, + 0, + `${patch} does not apply to pristine upstream:\n${r.stdout}\n${r.stderr}` + ); + } + } finally { + fs.rmSync(target, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/web/userscript.test.mjs b/tests/web/userscript.test.mjs new file mode 100644 index 0000000..4d2a6ff --- /dev/null +++ b/tests/web/userscript.test.mjs @@ -0,0 +1,47 @@ +// Web userscript demo tests (Phase 5, issue #5): syntax validity, +// Tampermonkey header contract, spoof surface, and documented limits. +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(here, '..', '..'); +const SCRIPT = path.join(ROOT, 'web-userscript', 'heretek-unlock.user.js'); +const text = fs.readFileSync(SCRIPT, 'utf8'); + +describe('web demo: userscript contract', () => { + it('parses as valid JavaScript', () => { + const r = spawnSync('node', ['--check', SCRIPT], { encoding: 'utf8' }); + assert.equal(r.status, 0, `syntax error:\n${r.stderr}`); + }); + + it('targets the web editor only, at document start', () => { + assert.ok(text.includes('@match https://editor.gdevelop.io/*')); + assert.ok(text.includes('@run-at document-start')); + assert.ok(text.includes('DEMO ONLY')); + }); + + it('spoofs exactly the Tier-1 usage endpoints', () => { + assert.ok(text.includes('https://api.gdevelop.io/usage')); + assert.ok(text.includes("'/limits'") || text.includes('/limits')); + assert.ok(text.includes('/subscription-v2')); + assert.ok(!text.includes('/generation'), 'demo must not touch generation'); + }); + + it('documents all three known limits in the header', () => { + assert.ok(text.includes('OLLAMA_ORIGINS'), 'Ollama CORS setup missing'); + assert.ok(text.includes('LAN preview'), 'LAN limitation missing'); + assert.ok( + text.includes('chunk') || text.includes('Chunk'), + 'hash-churn limitation missing' + ); + }); + + it('taps webpackChunk without module-factory surgery', () => { + assert.ok(text.includes('webpackChunk')); + assert.ok(!text.includes('.m =') && !text.includes('__webpack_require__')); + }); +}); diff --git a/web-userscript/README.md b/web-userscript/README.md new file mode 100644 index 0000000..acdf897 --- /dev/null +++ b/web-userscript/README.md @@ -0,0 +1,35 @@ +# web-userscript — web demo, optional (Phase 5, issue #5) + +Zero-install demo on `editor.gdevelop.io` only — not a delivery path for +the Studio loop. Non-blocking for Phase 6. + +## Mechanism (Vector D) + +`heretek-unlock.user.js` (Tampermonkey/Violentmonkey, `@run-at +document-start`): taps the `window.webpackChunk*` registry (convention +frozen by `tests/seams/electron.seam.test.mjs`) to **re-apply** `fetch` / +`XHR` wrappers on every chunk push — deliberately no module-factory +surgery (hash churn would break it on each rebuild). Spoofs only the two +Tier-1 GETs (`usage/limits`, `usage/subscription-v2`); generation is +untouched. A floating widget persists the BYOK base URL to the same +`gd-custom-ai-config` key the desktop client reads. + +## Ollama CORS setup (required for any BYOK call from the web demo) + +The editor page is `https`; Ollama serves `http://localhost:11434`, so +the browser blocks calls unless Ollama allows the origin: + +```bash +OLLAMA_ORIGINS=https://editor.gdevelop.io ollama serve +``` + +Without this, Tier-1 unlocks still work but every BYOK generation fails +with a CORS error in the console. Desktop builds do not need this. + +## Known limits + +- LAN preview unreachable from the hosted page (private IPs are not + routable from `editor.gdevelop.io`) — use desktop for wifi preview. +- No filesystem access; the Studio sub-agent loop is desktop-only. +- Chunk-hash churn may require reinstalling wrappers (handled + automatically) but can break the demo on any web rebuild. diff --git a/web-userscript/heretek-unlock.user.js b/web-userscript/heretek-unlock.user.js new file mode 100644 index 0000000..3c90836 --- /dev/null +++ b/web-userscript/heretek-unlock.user.js @@ -0,0 +1,190 @@ +// ==UserScript== +// @name GDevelop-Unlocked (web demo) +// @namespace https://github.com/Heretek-AI/GDevelop-Unlocked +// @version 0.1.0 +// @description DEMO ONLY: Tier-1 unlock (?Tier-2 BYOK config widget) on editor.gdevelop.io. Not a delivery path for the Studio loop. +// @match https://editor.gdevelop.io/* +// @grant none +// @run-at document-start +// ==/UserScript== +// +// DEMO ONLY — see web-userscript/README.md. Known limits: +// - Ollama CORS: this page is https, Ollama is http://localhost:11434. +// Start Ollama with OLLAMA_ORIGINS=https://editor.gdevelop.io (or a +// CORS proxy), otherwise the browser blocks the BYOK calls. +// - LAN preview is unreachable from the hosted page (private IPs are not +// routable from editor.gdevelop.io); use desktop for wifi preview. +// - No filesystem access; every web rebuild may change chunk hashes and +// break the webpackChunk tap below (re-install wrappers on each push). +(function () { + 'use strict'; + + var USAGE_BASE = 'https://api.gdevelop.io/usage'; + var LIMITS_PATH = '/limits'; + var SUB_PATH = '/subscription-v2'; + + var UNLOCKED_SUBSCRIPTION = { + userId: 'local-byok-user', + planId: 'gdevelop_startup', + pricingSystemId: 'HERETEK_UNLOCKED', + createdAt: 0, + updatedAt: 0, + }; + + var UNLOCKED_CAPABILITIES = { + analytics: { sessions: true, players: true, retention: true, sessionsTimeStats: true, platforms: true }, + cloudProjects: { maximumCount: 999, canMaximumCountBeIncreased: true, maximumGuestCollaboratorsPerProject: 99, projectRestorationTimeWindowInSeconds: -1, deletedProjectRetentionInDays: 365 }, + leaderboards: { maximumCountPerGame: 999, canMaximumCountPerGameBeIncreased: true, themeCustomizationCapabilities: 'FULL', canUseCustomCss: true, canDisableLoginInLeaderboard: true }, + multiplayer: { lobbiesCount: 99, maxPlayersPerLobby: 64, themeCustomizationCapabilities: 'FULL' }, + versionHistory: { enabled: true, retentionDays: 365 }, + ai: { availablePresets: [], versionHistory: { enabled: true, retentionDays: 365 } }, + }; + + function isSpoofed(url, method) { + if (typeof url !== 'string') return false; + if ((method || 'GET').toUpperCase() !== 'GET') return false; + if (url.indexOf(USAGE_BASE) !== 0) return false; + var rest = url.slice(USAGE_BASE.length); + return ( + rest === LIMITS_PATH || rest.indexOf(LIMITS_PATH + '?') === 0 || + rest === SUB_PATH || rest.indexOf(SUB_PATH + '?') === 0 + ); + } + + function bodyFor(url) { + var rest = url.slice(USAGE_BASE.length); + if (rest === LIMITS_PATH || rest.indexOf(LIMITS_PATH + '?') === 0) { + return { quotas: {}, capabilities: UNLOCKED_CAPABILITIES, credits: { userBalance: { amount: 999999 }, prices: {}, purchasableQuantities: [] } }; + } + return UNLOCKED_SUBSCRIPTION; + } + + function absUrl(url) { + if (/^https?:\/\//.test(url)) return url; + try { + return new URL(url, location.href).toString(); + } catch (e) { + return null; + } + } + + function installWrappers() { + if (window.__HERETEK_WEB__ && window.__HERETEK_WEB__.wrapped) return; + var originalFetch = window.fetch.bind(window); + window.fetch = function (input, init) { + var raw = typeof input === 'string' ? input : input && input.url; + var method = (init && init.method) || (input && input.method) || 'GET'; + var url = absUrl(raw); + if (url && isSpoofed(url, method)) { + return Promise.resolve( + new Response(JSON.stringify(bodyFor(url)), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + } + return originalFetch(input, init); + }; + + var XHR = window.XMLHttpRequest; + if (XHR && !XHR.prototype.__heretekWrapped) { + var origOpen = XHR.prototype.open; + var origSend = XHR.prototype.send; + XHR.prototype.open = function (method, url) { + this.__heretekMethod = method; + this.__heretekUrl = url; + return origOpen.apply(this, arguments); + }; + XHR.prototype.send = function () { + var self = this; + var args = arguments; + var url = absUrl(this.__heretekUrl); + if (url && isSpoofed(url, this.__heretekMethod)) { + var text = JSON.stringify(bodyFor(url)); + setTimeout(function () { + self.readyState = 4; + self.status = 200; + try { + self.responseText = text; + self.response = text; + } catch (e) { + /* getter-only prototype: best effort */ + } + if (self.onreadystatechange) self.onreadystatechange(); + if (self.onload) self.onload(); + }, 0); + return; + } + return origSend.apply(this, args); + }; + XHR.prototype.__heretekWrapped = true; + } + window.__HERETEK_WEB__ = { unlocked: true, wrapped: true }; + } + + // webpackChunk tap: every lazily pushed chunk re-runs the app bootstrap + // paths that capture fetch/XHR, so re-install (idempotent) on each push. + // Hash churn breaks module-index surgery on every rebuild — this tap + // deliberately does NOT touch module factories, only re-applies the + // transport wrappers above. + function tapWebpackChunks() { + try { + Object.keys(window) + .filter(function (k) { return k.indexOf('webpackChunk') === 0; }) + .forEach(function (k) { + var arr = window[k]; + if (arr && arr.push && !arr.__heretekTapped) { + var origPush = arr.push.bind(arr); + arr.push = function () { + var r = origPush.apply(null, arguments); + installWrappers(); + return r; + }; + arr.__heretekTapped = true; + } + }); + } catch (e) { + /* DOM not ready or chunks absent yet — retry below */ + } + } + + // Minimal BYOK config widget: persists the custom endpoint base URL to + // the same `gd-custom-ai-config` key the desktop client reads. Cosmetic + // demo aid — the Studio loop itself is desktop-only. + function mountConfigWidget() { + if (document.getElementById('heretek-byok-demo')) return; + var box = document.createElement('div'); + box.id = 'heretek-byok-demo'; + box.style.cssText = + 'position:fixed;bottom:12px;right:12px;z-index:99999;background:#111;color:#eee;' + + 'border:1px solid #6c6;font:12px sans-serif;padding:8px 10px;border-radius:8px;opacity:.92'; + box.innerHTML = + 'Heretek web demo (Tier-1 unlocked)
' + + ' ' + + ' '; + document.documentElement.appendChild(box); + document.getElementById('heretek-save').onclick = function () { + var base = document.getElementById('heretek-base').value.trim(); + try { + var raw = localStorage.getItem('gd-custom-ai-config'); + var cfg = raw ? JSON.parse(raw) : {}; + cfg.enabled = true; + cfg.baseUrl = base; + localStorage.setItem('gd-custom-ai-config', JSON.stringify(cfg)); + document.getElementById('heretek-msg').textContent = 'saved (needs Ollama CORS ORIGINS, see header)'; + } catch (e) { + document.getElementById('heretek-msg').textContent = 'save failed: ' + e.message; + } + }; + } + + installWrappers(); + tapWebpackChunks(); + var chunksTimer = setInterval(function () { + tapWebpackChunks(); + if (document.documentElement) { + mountConfigWidget(); + clearInterval(chunksTimer); + } + }, 500); +})();