mirror of
https://github.com/Heretek-AI/GDevelop-Unlocked.git
synced 2026-11-07 10:55:58 -10:00
T03: Tool Manifest Pruning (#24) - offline docs fail-closed + PlanStore plan routing
- CustomAIClient: search_docs/read_full_docs offline suffix + OFFLINE_DOCS helpers - Patch 14: local pre-pass for docs (offline:true, no network) + create_or_update_plan via PlanStore validate/merge - Tests: 3 new assertions in ai-byok-flow.test.mjs proving no dead tool calls - MANIFEST: recompute CustomAIClient hash/bytes
This commit is contained in:
1 parent
67a4be1b29
commit
0276353b64
4 files changed
+312
-28
No files matched your search
@@ -2086,7 +2086,7 @@ export const GDEVELOP_OPENAI_TOOLS: Array<{|
|
||||
function: {
|
||||
name: 'search_docs',
|
||||
description:
|
||||
'Search GDevelop documentation for functions, behaviors, and instructions.',
|
||||
'Search GDevelop documentation for functions, behaviors, and instructions. [Offline in local BYOK mode: rely on internal GDevelop knowledge and on-project verification]',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -2101,7 +2101,7 @@ export const GDEVELOP_OPENAI_TOOLS: Array<{|
|
||||
function: {
|
||||
name: 'read_full_docs',
|
||||
description:
|
||||
'Read full documentation for specific GDevelop extensions or features.',
|
||||
'Read full documentation for specific GDevelop extensions or features. [Offline in local BYOK mode: rely on internal GDevelop knowledge and on-project verification]',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -3970,6 +3970,50 @@ export const BACKEND_ONLY_TOOL_NAMES: Array<string> = [
|
||||
'get_game_starter_summary',
|
||||
];
|
||||
|
||||
/**
|
||||
* T03 (#24) offline docs tools: the hosted docs backend is unreachable in
|
||||
* local BYOK mode. Their descriptions carry the offline suffix (above) and
|
||||
* every local call fails closed immediately with no network lag — see
|
||||
* `buildOfflineDocsResult` and the Utils.js pre-pass that routes them
|
||||
* before the editor dispatch.
|
||||
*/
|
||||
export const OFFLINE_DOCS_SUFFIX: string =
|
||||
' [Offline in local BYOK mode: rely on internal GDevelop knowledge and on-project verification]';
|
||||
|
||||
export const OFFLINE_DOCS_MESSAGE: string =
|
||||
'Documentation backend is offline in local mode. Rely on internal GDevelop knowledge and verify against the open project.';
|
||||
|
||||
export const OFFLINE_DOCS_TOOL_NAMES: Array<string> = [
|
||||
'search_docs',
|
||||
'read_full_docs',
|
||||
];
|
||||
|
||||
export const isOfflineDocsTool = (toolName: string): boolean =>
|
||||
OFFLINE_DOCS_TOOL_NAMES.includes(toolName);
|
||||
|
||||
/**
|
||||
* Fail-closed result for an offline docs call. Synchronous with no network
|
||||
* I/O so a docs lookup can never stall a local turn.
|
||||
*/
|
||||
export const buildOfflineDocsResult = (toolName: string): Object => ({
|
||||
success: false,
|
||||
offline: true,
|
||||
message: `${OFFLINE_DOCS_MESSAGE} (tool: ${toolName})`,
|
||||
});
|
||||
|
||||
/**
|
||||
* T03 (#24) local plan tool: `create_or_update_plan` is uncallable upstream
|
||||
* (`EditorFunctions/index.js` fails it as "handled server-side"). Under BYOK
|
||||
* it is routed to `Studio/PlanStore.js` (validate + merge over the request's
|
||||
* existing plan) by the Utils.js pre-pass — never to the editor dispatch.
|
||||
* Kept in `GDEVELOP_OPENAI_TOOLS` so the manager keeps planning; the pre-pass
|
||||
* is what makes it a live tool instead of a dead one.
|
||||
*/
|
||||
export const LOCAL_PLAN_TOOL_NAMES: Array<string> = ['create_or_update_plan'];
|
||||
|
||||
export const isLocalPlanTool = (toolName: string): boolean =>
|
||||
LOCAL_PLAN_TOOL_NAMES.includes(toolName);
|
||||
|
||||
export const withoutBackendOnlyTools = (tools: Array<Object>): Array<Object> =>
|
||||
tools.filter(
|
||||
tool =>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"files": {
|
||||
"AI/CustomAIClient.js": {
|
||||
"bytes": 208828,
|
||||
"bytes": 210819,
|
||||
"origin": "newIDE/app/src/AI/CustomAIClient.js",
|
||||
"sha256": "2dd976b936908115d08374851121a077d2b2b6c314a029a4bc52ab7125f51723"
|
||||
"sha256": "0934ccb0060cf2c5607ecb192d855189d3fdc679545ee41ad748cd81f642a64b"
|
||||
},
|
||||
"AI/CustomAIClient.spec.js": {
|
||||
"bytes": 257794,
|
||||
@@ -102,5 +102,5 @@
|
||||
"sha256": "45c2915841b0a6c0f17db7a297a39ca1dfb8b889d74f018f87d1815cadf17c26"
|
||||
}
|
||||
},
|
||||
"provenance": "Historical origin only: extracted in 2026-10 from the Heretek fork (now retired). This repo never reads the donor \u2014 integrity is self-contained via the pinned bytes/sha256 below."
|
||||
"provenance": "Historical origin only: extracted in 2026-10 from the Heretek fork (now retired). This repo never reads the donor — integrity is self-contained via the pinned bytes/sha256 below."
|
||||
}
|
||||
+118
-23
@@ -17,13 +17,16 @@
|
||||
} from './AiRequestUtils';
|
||||
import { useEnsureExtensionInstalled } from './UseEnsureExtensionInstalled';
|
||||
import { useGenerateEvents } from './UseGenerateEvents';
|
||||
@@ -62,6 +63,31 @@
|
||||
@@ -62,6 +63,37 @@
|
||||
import { makeSimplifiedProjectBuilder } from '../EditorFunctions/SimplifiedProject/SimplifiedProject';
|
||||
import { prepareAiUserContent } from './PrepareAiUserContent';
|
||||
import { extractGDevelopApiErrorStatusAndCode } from '../Utils/GDevelopServices/Errors';
|
||||
+import {
|
||||
+ isCustomEndpointEnabled,
|
||||
+ LOCAL_BYOK_USER_ID,
|
||||
+ isOfflineDocsTool,
|
||||
+ OFFLINE_DOCS_MESSAGE,
|
||||
+ isLocalPlanTool,
|
||||
+} from '../AI/CustomAIClient';
|
||||
+import {
|
||||
+ isSpawnAgentCall,
|
||||
@@ -35,6 +38,9 @@
|
||||
+import { createLoopGuard } from './Studio/LoopGuard';
|
||||
+import {
|
||||
+ mergePlanResultOutput,
|
||||
+ mergePlanTasks,
|
||||
+ validatePlanTasks,
|
||||
+ buildPlanOutput,
|
||||
+ patchPlanTaskAsDelegated,
|
||||
+ replacePlanMessage,
|
||||
+} from './Studio/PlanStore';
|
||||
@@ -49,7 +55,7 @@
|
||||
import UnsavedChangesContext from '../MainFrame/UnsavedChangesContext';
|
||||
import {
|
||||
type FileMetadata,
|
||||
@@ -213,12 +239,10 @@
|
||||
@@ -213,12 +245,10 @@
|
||||
editorCallbacks: EditorCallbacks,
|
||||
|}): React.Node => {
|
||||
if (aiRequest.parentAiRequestId) {
|
||||
@@ -66,7 +72,7 @@
|
||||
if (launchingCall) {
|
||||
return renderFunctionCallLabel({
|
||||
functionCall: launchingCall,
|
||||
@@ -244,6 +268,7 @@
|
||||
@@ -244,6 +274,7 @@
|
||||
editorCallbacks,
|
||||
aiRequestsToProcess,
|
||||
onSendEditorFunctionCallResults,
|
||||
@@ -74,7 +80,7 @@
|
||||
getEditorFunctionCallResults,
|
||||
addEditorFunctionCallResults,
|
||||
onSceneEventsModifiedOutsideEditor,
|
||||
@@ -265,6 +290,9 @@
|
||||
@@ -265,6 +296,9 @@
|
||||
suspendAiRequest,
|
||||
requestEditApproval,
|
||||
onSaveProjectAsWithStorageProvider,
|
||||
@@ -84,7 +90,7 @@
|
||||
}: {|
|
||||
i18n: I18nType,
|
||||
project: ?gdProject,
|
||||
@@ -280,7 +308,7 @@
|
||||
@@ -280,7 +314,7 @@
|
||||
createdExternalLayoutNames?: Array<string>,
|
||||
createdProject?: ?gdProject,
|
||||
|}
|
||||
@@ -93,7 +99,7 @@
|
||||
getEditorFunctionCallResults: string => Array<EditorFunctionCallResult> | null,
|
||||
addEditorFunctionCallResults: (
|
||||
string,
|
||||
@@ -319,6 +347,9 @@
|
||||
@@ -319,6 +353,9 @@
|
||||
onWillInstallExtension: (extensionNames: Array<string>) => void,
|
||||
onExtensionInstalled: (extensionNames: Array<string>) => void,
|
||||
isReadyToProcessFunctionCalls: boolean,
|
||||
@@ -103,7 +109,7 @@
|
||||
getIsAutoEditEnabled: () => boolean,
|
||||
suspendAiRequest: (aiRequestId: string) => Promise<void>,
|
||||
requestEditApproval: (request: EditApprovalRequest) => Promise<boolean>,
|
||||
@@ -331,12 +362,30 @@
|
||||
@@ -331,12 +368,30 @@
|
||||
createdProject?: gdProject,
|
||||
|}
|
||||
) => Promise<?FileMetadata>,
|
||||
@@ -134,7 +140,7 @@
|
||||
} => {
|
||||
const { ensureExtensionInstalled } = useEnsureExtensionInstalled({
|
||||
project,
|
||||
@@ -424,6 +473,14 @@
|
||||
@@ -424,6 +479,14 @@
|
||||
// `call:<callId>` (for a single direct modifying call like generate_events).
|
||||
const approvedEditBatchKeysRef = React.useRef<Set<string>>(new Set());
|
||||
|
||||
@@ -149,7 +155,7 @@
|
||||
// Forget all previously-granted edit approvals so the next modifying call
|
||||
// asks again. Called when the user toggles auto-edit: turning it on then off
|
||||
// again means they want to review the upcoming edits, even within a sub-agent
|
||||
@@ -440,6 +497,16 @@
|
||||
@@ -440,6 +503,16 @@
|
||||
if (!isReadyToProcessFunctionCalls) return;
|
||||
if (aiRequest.status === 'suspended') return;
|
||||
|
||||
@@ -166,7 +172,7 @@
|
||||
const functionCallsToProcess = functionCalls.filter(
|
||||
functionCall =>
|
||||
!inFlightFunctionCallIdsRef.current.has(
|
||||
@@ -460,14 +527,60 @@
|
||||
@@ -460,14 +533,60 @@
|
||||
);
|
||||
});
|
||||
|
||||
@@ -229,7 +235,7 @@
|
||||
|
||||
// Gate project-modifying calls behind a user confirmation when auto-edit
|
||||
// is off. Read-only calls (exploration, inspection) always run. The first
|
||||
@@ -493,10 +606,16 @@
|
||||
@@ -493,10 +612,16 @@
|
||||
const modifyingFunctionCalls = functionCallsToProcess.filter(
|
||||
functionCall =>
|
||||
doesFunctionCallModifyProject(functionCall) &&
|
||||
@@ -246,7 +252,7 @@
|
||||
!isCallApproved(functionCall)
|
||||
);
|
||||
|
||||
@@ -554,13 +673,278 @@
|
||||
@@ -554,13 +679,365 @@
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,6 +363,89 @@
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ // T03 (#24): offline docs + local plan pre-pass. Both are dead upstream
|
||||
+ // (EditorFunctions fails them as "handled server-side"), so they are
|
||||
+ // routed locally with zero network I/O before the editor dispatch. A
|
||||
+ // docs call fails closed with offline:true; a plan call validates via
|
||||
+ // PlanStore and folds over the existing plan (preserving agentCallId).
|
||||
+ const offlineDocsHandledCallIds = new Set<string>();
|
||||
+ const offlineDocsResults: Array<EditorFunctionCallResult> = [];
|
||||
+ const localPlanHandledCallIds = new Set<string>();
|
||||
+ const localPlanResults: Array<EditorFunctionCallResult> = [];
|
||||
+ if (isCustomEndpointEnabled()) {
|
||||
+ for (const functionCall of functionCallsToProcess) {
|
||||
+ if (studioDeniedCallIds.has(functionCall.call_id)) continue;
|
||||
+ if (loopGuardHandledCallIds.has(functionCall.call_id)) continue;
|
||||
+ const isDocs =
|
||||
+ functionCall.name === 'search_docs' ||
|
||||
+ functionCall.name === 'read_full_docs' ||
|
||||
+ (typeof isOfflineDocsTool === 'function' &&
|
||||
+ isOfflineDocsTool(functionCall.name));
|
||||
+ if (isDocs) {
|
||||
+ offlineDocsHandledCallIds.add(functionCall.call_id);
|
||||
+ const docsDenial = {
|
||||
+ status: 'finished',
|
||||
+ call_id: functionCall.call_id,
|
||||
+ success: false,
|
||||
+ output: {
|
||||
+ message: `${OFFLINE_DOCS_MESSAGE} (tool: ${functionCall.name})`,
|
||||
+ offline: true,
|
||||
+ },
|
||||
+ };
|
||||
+ offlineDocsResults.push(docsDenial);
|
||||
+ addEditorFunctionCallResults(aiRequest.id, [docsDenial]);
|
||||
+ continue;
|
||||
+ }
|
||||
+ const isPlan =
|
||||
+ functionCall.name === 'create_or_update_plan' ||
|
||||
+ (typeof isLocalPlanTool === 'function' &&
|
||||
+ isLocalPlanTool(functionCall.name));
|
||||
+ if (isPlan) {
|
||||
+ localPlanHandledCallIds.add(functionCall.call_id);
|
||||
+ let planResult;
|
||||
+ try {
|
||||
+ const parsedPlanArgs = JSON.parse(functionCall.arguments || '{}');
|
||||
+ const validation = validatePlanTasks(
|
||||
+ parsedPlanArgs ? parsedPlanArgs.tasks : null
|
||||
+ );
|
||||
+ if (!validation.success) {
|
||||
+ planResult = {
|
||||
+ status: 'finished',
|
||||
+ call_id: functionCall.call_id,
|
||||
+ success: false,
|
||||
+ output: { message: validation.message },
|
||||
+ };
|
||||
+ } else {
|
||||
+ const currentPlan = getLatestActivePlan(aiRequest);
|
||||
+ const currentTasks =
|
||||
+ currentPlan && Array.isArray(currentPlan.tasks)
|
||||
+ ? currentPlan.tasks
|
||||
+ : [];
|
||||
+ const merged =
|
||||
+ currentTasks.length > 0
|
||||
+ ? mergePlanTasks(currentTasks, validation.tasks)
|
||||
+ : validation.tasks;
|
||||
+ planResult = {
|
||||
+ status: 'finished',
|
||||
+ call_id: functionCall.call_id,
|
||||
+ success: true,
|
||||
+ output: buildPlanOutput(merged),
|
||||
+ };
|
||||
+ }
|
||||
+ } catch (error) {
|
||||
+ planResult = {
|
||||
+ status: 'finished',
|
||||
+ call_id: functionCall.call_id,
|
||||
+ success: false,
|
||||
+ output: { message: 'Invalid plan arguments: not valid JSON.' },
|
||||
+ };
|
||||
+ }
|
||||
+ localPlanResults.push(planResult);
|
||||
+ addEditorFunctionCallResults(aiRequest.id, [planResult]);
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ // The local studio's delegation tool is not an editor function: it creates
|
||||
+ // a child AI request and stamps the call so the shared loop stops treating
|
||||
+ // it as a call for the editor to execute (`getFunctionCallsToProcess`
|
||||
@@ -487,7 +576,9 @@
|
||||
+ functionCall =>
|
||||
+ !spawnHandledCallIds.has(functionCall.call_id) &&
|
||||
+ !loopGuardHandledCallIds.has(functionCall.call_id) &&
|
||||
+ !studioDeniedCallIds.has(functionCall.call_id)
|
||||
+ !studioDeniedCallIds.has(functionCall.call_id) &&
|
||||
+ !offlineDocsHandledCallIds.has(functionCall.call_id) &&
|
||||
+ !localPlanHandledCallIds.has(functionCall.call_id)
|
||||
+ );
|
||||
+ // Mark only the editor calls as "working": a spawn (or a loop-guard hit)
|
||||
+ // already has a terminal result, and a permanent "working" entry would
|
||||
@@ -508,6 +599,8 @@
|
||||
+ ...studioDeniedResults,
|
||||
+ ...loopGuardResults,
|
||||
+ ...spawnFailureResults,
|
||||
+ ...offlineDocsResults,
|
||||
+ ...localPlanResults,
|
||||
+ ];
|
||||
+ if (outcomeResults.length > 0) {
|
||||
+ await onSendEditorFunctionCallResults(
|
||||
@@ -526,7 +619,7 @@
|
||||
|
||||
// The "modified outside editor" callbacks each refresh the editor and can
|
||||
// trigger an in-game editor hot reload. Firing them once per function
|
||||
@@ -669,7 +1053,7 @@
|
||||
@@ -669,7 +1146,7 @@
|
||||
// Explorer sub-agent scripts are read-only: restrict their
|
||||
// `run_script` to non-mutating functions (defense in depth).
|
||||
runScriptReadOnly: isReadOnlyScriptContext,
|
||||
@@ -535,7 +628,7 @@
|
||||
name: functionCall.name,
|
||||
arguments: functionCall.arguments,
|
||||
call_id: functionCall.call_id,
|
||||
@@ -761,13 +1145,52 @@
|
||||
@@ -761,13 +1238,54 @@
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -579,6 +672,8 @@
|
||||
+ ...studioDeniedResults,
|
||||
+ ...loopGuardResults,
|
||||
+ ...spawnFailureResults,
|
||||
+ ...offlineDocsResults,
|
||||
+ ...localPlanResults,
|
||||
+ ...mergedResults,
|
||||
+ ],
|
||||
+ {
|
||||
@@ -593,7 +688,7 @@
|
||||
} finally {
|
||||
// Flush the coalesced editor notifications for everything modified in
|
||||
// this batch (one hot reload instead of one per call). In `finally` so
|
||||
@@ -787,7 +1210,9 @@
|
||||
@@ -787,7 +1305,9 @@
|
||||
}
|
||||
|
||||
// Release the lock so these calls can be retried if needed
|
||||
@@ -604,7 +699,7 @@
|
||||
functionCallsToProcess.forEach(functionCall => {
|
||||
inFlightFunctionCallIdsRef.current.delete(
|
||||
`${aiRequest.id}:${functionCall.call_id}`
|
||||
@@ -824,9 +1249,12 @@
|
||||
@@ -824,9 +1344,12 @@
|
||||
getAssetStoreTagForNewObject,
|
||||
generateEvents,
|
||||
onSendEditorFunctionCallResults,
|
||||
@@ -617,7 +712,7 @@
|
||||
]
|
||||
);
|
||||
|
||||
@@ -870,9 +1298,50 @@
|
||||
@@ -870,9 +1393,50 @@
|
||||
[onProcessFunctionCalls, allFunctionCallsToProcess]
|
||||
);
|
||||
|
||||
@@ -668,7 +763,7 @@
|
||||
};
|
||||
};
|
||||
|
||||
@@ -977,7 +1446,6 @@
|
||||
@@ -977,7 +1541,6 @@
|
||||
editorFunctionCallResultsStorage,
|
||||
isFetchingSuggestions,
|
||||
setIsFetchingSuggestions,
|
||||
@@ -676,7 +771,7 @@
|
||||
setSelectedAiRequestId,
|
||||
selectedAiRequest,
|
||||
} = React.useContext(AiRequestContext);
|
||||
@@ -1036,20 +1504,19 @@
|
||||
@@ -1036,20 +1599,19 @@
|
||||
// Then ask for some.
|
||||
if (
|
||||
!selectedAiRequest ||
|
||||
@@ -708,7 +803,7 @@
|
||||
|
||||
// Check if there are tools being run. If so, no suggestions at this time.
|
||||
const hasFunctionsCallsToProcess =
|
||||
@@ -1139,7 +1606,7 @@
|
||||
@@ -1139,7 +1701,7 @@
|
||||
: null;
|
||||
const preparedAiUserContent = await prepareAiUserContent({
|
||||
getAuthorizationHeader,
|
||||
@@ -717,7 +812,7 @@
|
||||
simplifiedProjectJson,
|
||||
projectSpecificExtensionsSummaryJson,
|
||||
eventsJson: null,
|
||||
@@ -1153,7 +1620,7 @@
|
||||
@@ -1153,7 +1715,7 @@
|
||||
const aiRequestWorkingForSuggestions = await getAiRequestSuggestions(
|
||||
getAuthorizationHeader,
|
||||
{
|
||||
@@ -726,7 +821,7 @@
|
||||
aiRequestId: selectedAiRequest.id,
|
||||
suggestionsType: isLastMessageFunctionCallOutputProjectInitialization
|
||||
? 'list-with-explanations'
|
||||
@@ -1525,39 +1992,12 @@
|
||||
@@ -1525,39 +2087,12 @@
|
||||
[profile, setSelectedAiRequestId]
|
||||
);
|
||||
|
||||
|
||||
@@ -343,3 +343,148 @@ describe('integration: BYOK tool schemas, zero-egress routing, patch-level leak
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration: T03 tool manifest pruning (no dead tool calls)', () => {
|
||||
it('search_docs/read_full_docs carry the offline suffix and fail closed with no network', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const { fileURLToPath } = await import('node:url');
|
||||
const { dirname, resolve } = await import('node:path');
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const src = readFileSync(
|
||||
resolve(root, 'packages/ai-studio/AI/CustomAIClient.js'),
|
||||
'utf8'
|
||||
);
|
||||
const suffix =
|
||||
'[Offline in local BYOK mode: rely on internal GDevelop knowledge and on-project verification]';
|
||||
assert.ok(
|
||||
src.includes(suffix),
|
||||
'offline suffix missing from CustomAIClient tool descriptions'
|
||||
);
|
||||
// Both docs tools must carry it.
|
||||
const searchAt = src.indexOf("name: 'search_docs'");
|
||||
const readAt = src.indexOf("name: 'read_full_docs'");
|
||||
assert.ok(searchAt !== -1 && readAt !== -1, 'docs tools missing from schema');
|
||||
assert.ok(
|
||||
src.slice(searchAt, searchAt + 800).includes(suffix),
|
||||
'search_docs description lacks offline suffix'
|
||||
);
|
||||
assert.ok(
|
||||
src.slice(readAt, readAt + 800).includes(suffix),
|
||||
'read_full_docs description lacks offline suffix'
|
||||
);
|
||||
// Fail-closed helper: synchronous, offline:true, exact message, no fetch.
|
||||
assert.ok(
|
||||
src.includes('OFFLINE_DOCS_MESSAGE'),
|
||||
'OFFLINE_DOCS_MESSAGE export missing'
|
||||
);
|
||||
assert.ok(
|
||||
src.includes('Documentation backend is offline in local mode'),
|
||||
'offline docs message text missing'
|
||||
);
|
||||
assert.ok(
|
||||
src.includes('buildOfflineDocsResult'),
|
||||
'buildOfflineDocsResult helper missing'
|
||||
);
|
||||
assert.ok(
|
||||
src.includes('isOfflineDocsTool'),
|
||||
'isOfflineDocsTool helper missing'
|
||||
);
|
||||
const helperAt = src.indexOf('export const buildOfflineDocsResult');
|
||||
assert.ok(helperAt !== -1, 'buildOfflineDocsResult export missing');
|
||||
const helperSlice = src.slice(helperAt, helperAt + 2000);
|
||||
assert.ok(helperSlice.includes('offline: true'), 'docs result must set offline:true');
|
||||
assert.ok(helperSlice.includes('success: false'), 'docs result must be success:false');
|
||||
assert.ok(!helperSlice.includes('fetch('), 'offline docs result must not fetch (no network lag)');
|
||||
assert.ok(!helperSlice.includes('axios'), 'offline docs result must not use axios');
|
||||
// Zero egress: the offline message path never mentions the hosted API.
|
||||
assert.ok(
|
||||
!src.includes('api.gdevelop.io'),
|
||||
'BYOK client must not hardcode the hosted API'
|
||||
);
|
||||
});
|
||||
|
||||
it('create_or_update_plan routes to PlanStore locally, never to upstream EditorFunctions', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const { fileURLToPath } = await import('node:url');
|
||||
const { dirname, resolve } = await import('node:path');
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const client = readFileSync(
|
||||
resolve(root, 'packages/ai-studio/AI/CustomAIClient.js'),
|
||||
'utf8'
|
||||
);
|
||||
const patch14 = readFileSync(resolve(root, 'patches/14-ai-utils.patch'), 'utf8');
|
||||
// Schema keeps the tool so the manager keeps planning.
|
||||
assert.ok(
|
||||
client.includes("name: 'create_or_update_plan'"),
|
||||
'plan tool missing from GDEVELOP_OPENAI_TOOLS'
|
||||
);
|
||||
assert.ok(
|
||||
client.includes('isLocalPlanTool'),
|
||||
'isLocalPlanTool marker missing (T03 plan routing)'
|
||||
);
|
||||
// Patch 14 pre-pass: local PlanStore routing before the editor dispatch.
|
||||
for (const marker of [
|
||||
'offlineDocsHandledCallIds',
|
||||
'localPlanHandledCallIds',
|
||||
'validatePlanTasks',
|
||||
'buildPlanOutput',
|
||||
'mergePlanTasks',
|
||||
'OFFLINE_DOCS_MESSAGE',
|
||||
]) {
|
||||
assert.ok(patch14.includes(marker), `patch 14 missing T03 marker: ${marker}`);
|
||||
}
|
||||
// Both pre-pass results are excluded from the editor and surfaced to the model.
|
||||
assert.ok(
|
||||
patch14.includes('!offlineDocsHandledCallIds.has(functionCall.call_id)'),
|
||||
'docs calls must be excluded from editor dispatch'
|
||||
);
|
||||
assert.ok(
|
||||
patch14.includes('!localPlanHandledCallIds.has(functionCall.call_id)'),
|
||||
'plan calls must be excluded from editor dispatch'
|
||||
);
|
||||
assert.ok(
|
||||
patch14.includes('...offlineDocsResults,'),
|
||||
'docs results must be sent to the model'
|
||||
);
|
||||
assert.ok(
|
||||
patch14.includes('...localPlanResults,'),
|
||||
'plan results must be sent to the model'
|
||||
);
|
||||
// No network in the T03 pre-pass: docs fail closed synchronously.
|
||||
const preAt = patch14.indexOf('T03 (#24)');
|
||||
assert.ok(preAt !== -1, 'T03 pre-pass comment missing in patch 14');
|
||||
const preSlice = patch14.slice(preAt, preAt + 6000);
|
||||
assert.ok(!preSlice.includes('fetch('), 'T03 pre-pass must not fetch');
|
||||
});
|
||||
|
||||
it('every GDEVELOP_OPENAI_TOOLS entry is dispatchable locally (no dead tools)', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const { fileURLToPath } = await import('node:url');
|
||||
const { dirname, resolve } = await import('node:path');
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const src = readFileSync(
|
||||
resolve(root, 'packages/ai-studio/AI/CustomAIClient.js'),
|
||||
'utf8'
|
||||
);
|
||||
const patch14 = readFileSync(resolve(root, 'patches/14-ai-utils.patch'), 'utf8');
|
||||
const toolRe =
|
||||
/\{\s*type:\s*'function',\s*function:\s*\{\s*name:\s*'([^']+)',\s*description:\s*('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")/g;
|
||||
const names = [];
|
||||
let m;
|
||||
while ((m = toolRe.exec(src)) !== null) names.push(m[1]);
|
||||
assert.equal(names.length, 39, `expected 39 tools, found ${names.length}`);
|
||||
// The three historically dead tools now have explicit local handling.
|
||||
for (const dead of ['search_docs', 'read_full_docs', 'create_or_update_plan']) {
|
||||
assert.ok(names.includes(dead), `tool missing from schema: ${dead}`);
|
||||
assert.ok(
|
||||
patch14.includes(dead),
|
||||
`patch 14 has no local route for historically dead tool: ${dead}`
|
||||
);
|
||||
}
|
||||
// Backend-only tools stay in schema for hosted turns but are withheld locally.
|
||||
assert.ok(
|
||||
src.includes('BACKEND_ONLY_TOOL_NAMES'),
|
||||
'backend-only filter missing'
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user