Allow edition by the agent of large JS code blocks (#9181)

This commit is contained in:
Florian Rival
2026-10-05 14:48:30 +02:00
committed by GitHub
parent fdb154ce95
commit d45fcdb80a
12 changed files with 1401 additions and 38 deletions
+1 -1
View File
@@ -112,7 +112,7 @@ export const useRefreshLimits = (
// The tools of the orchestrator AND of the sub-agents it creates server-side.
// Only bump it once the matching prompts and generation-api are deployed;
// reverting it is the flip-back (every past version stays served).
export const AI_ORCHESTRATOR_TOOLS_VERSION: string = 'v20';
export const AI_ORCHESTRATOR_TOOLS_VERSION: string = 'v21';
/**
* A pending request for the user to approve (or refuse) a project-modifying
@@ -1,6 +1,7 @@
// @flow
import { editorFunctions, type EditorFunctionGenericOutput } from './index';
import { makeFakeLaunchFunctionOptionsWithProject } from './TestHelpers';
import { unserializeFromJSObject } from '../Utils/Serializer';
const gd: libGDevelop = global.gd;
@@ -440,6 +441,242 @@ describe('add_scene_events', () => {
expect(eventBatches[0].expectedEventSource).toBe('js(Player) """');
});
describe('edits', () => {
const launchWithEdits = (
generateEvents: any,
eventBatch: Object
): Promise<EditorFunctionGenericOutput> =>
editorFunctions.generate_events.launchFunction({
...makeFakeLaunchFunctionOptionsWithProject(project),
generateEvents,
args: {
scope: { type: 'scene', scene_name: 'TestScene' },
extension_names_list: '',
objects_list: 'Player',
event_batches: [
{
placement_relation: 'replace_event_but_keep_existing_sub_events',
placement_target_event_id: 'event-0',
placement_rationale: 'The same event.',
...eventBatch,
},
],
},
});
const addJsCodeEvent = (code: string) => {
const jsCodeEvent = gd.asJsCodeEvent(
project
.getLayout('TestScene')
.getEvents()
.insertNewEvent(project, 'BuiltinCommonInstructions::JsCode', 0)
);
jsCodeEvent.setInlineCode(code);
jsCodeEvent.setParameterObjects('Player');
};
const getJsCode = () =>
gd
.asJsCodeEvent(
project
.getLayout('TestScene')
.getEvents()
.getEventAt(0)
)
.getInlineCode();
it('changes the code of a `js` event directly and returns the lines around the change', async () => {
addJsCodeEvent(
['const a = 1;', 'const b = 2;', 'const c = 3;'].join('\n')
);
// $FlowFixMe[underconstrained-implicit-instantiation]
const generateEvents = jest.fn();
const result = await launchWithEdits(generateEvents, {
edits: [{ old_string: 'const b = 2;', new_string: 'const b = 20;' }],
});
expect(generateEvents).not.toHaveBeenCalled();
expect(getJsCode()).toBe(
['const a = 1;', 'const b = 20;', 'const c = 3;'].join('\n')
);
expect(result.success).toBe(true);
expect(result.editedEventSnippets).toEqual([
{
eventId: 'event-0',
replacements: 'Edit 1 replaced 1 occurrence (line 2).',
snippet: [
'1\tconst a = 1;',
'2\tconst b = 20;',
'3\tconst c = 3;',
].join('\n'),
},
]);
});
it('changes the code of a `js` event of any size', async () => {
const lines = Array.from(
{ length: 20000 },
(_, index) => `const value${index} = ${index};`
);
addJsCodeEvent(lines.join('\n'));
// $FlowFixMe[underconstrained-implicit-instantiation]
const generateEvents = jest.fn();
const result = await launchWithEdits(generateEvents, {
edits: [
{
old_string: 'const value19999 = 19999;',
new_string: 'const value19999 = -1;',
},
],
});
expect(result.success).toBe(true);
expect(getJsCode().length).toBeGreaterThan(400000);
expect(getJsCode().endsWith('const value19999 = -1;')).toBe(true);
});
it('refuses to mix the edits of a `js` event with batches generating events', async () => {
addJsCodeEvent('const a = 1;');
// $FlowFixMe[underconstrained-implicit-instantiation]
const generateEvents = jest.fn();
const result = await editorFunctions.generate_events.launchFunction({
...makeFakeLaunchFunctionOptionsWithProject(project),
generateEvents,
args: {
scope: { type: 'scene', scene_name: 'TestScene' },
extension_names_list: '',
objects_list: '',
event_batches: [
{
placement_relation: 'replace_event_but_keep_existing_sub_events',
placement_target_event_id: 'event-0',
edits: [
{ old_string: 'const a = 1;', new_string: 'const a = 2;' },
],
},
{
placement_relation: 'append_to_end',
event_script: 'always:\n pass',
},
],
},
});
expect(result.success).toBe(false);
expect(result.message).toContain(
'in a `generate_events` call of their own'
);
expect(getJsCode()).toBe('const a = 1;');
expect(generateEvents).not.toHaveBeenCalled();
});
it('changes an event from a text copied with its indentation among the other events', async () => {
unserializeFromJSObject(
project.getLayout('TestScene').getEvents(),
[
{
type: 'BuiltinCommonInstructions::Standard',
conditions: [],
actions: [],
events: [
{
type: 'BuiltinCommonInstructions::Standard',
conditions: [],
actions: [
{
type: { value: 'Delete' },
parameters: ['MySpriteObject', ''],
},
],
},
],
},
],
'unserializeFrom',
project
);
const read = await editorFunctions.read_events_source.launchFunction({
...makeFakeLaunchFunctionOptionsWithProject(project),
args: {
scope: { type: 'scene', scene_name: 'TestScene' },
event_ids: ['event-0.0'],
},
});
const actionLine = (read.eventScript || '')
.split('\n')
.find(line => line.includes('MySpriteObject'));
if (!actionLine) throw new Error('Expected the action to be read.');
expect(actionLine.startsWith(' ')).toBe(true);
// $FlowFixMe[underconstrained-implicit-instantiation]
const generateEvents = jest.fn();
await launchWithEdits(generateEvents, {
placement_target_event_id: 'event-0.0',
edits: [
{
old_string: actionLine,
new_string: actionLine.replace('MySpriteObject', 'Player'),
},
],
});
const { eventBatches } = generateEvents.mock.calls[0][0];
expect(eventBatches[0].isEventScriptFromEdits).toBe(true);
expect(eventBatches[0].eventScript.split('\n')).toContain(
` ${actionLine.trim().replace('MySpriteObject', 'Player')}`
);
expect(eventBatches[0].eventScript).not.toContain('MySpriteObject');
});
it('changes nothing when an `old_string` is not found or found several times', async () => {
addJsCodeEvent(
['const a = 1;', 'const a = 1;', 'const b = 2;'].join('\n')
);
// $FlowFixMe[underconstrained-implicit-instantiation]
const generateEvents = jest.fn();
const notFound = await launchWithEdits(generateEvents, {
edits: [{ old_string: 'const b = 2;\nconst c = 3;', new_string: '' }],
});
expect(notFound.success).toBe(false);
expect(notFound.message).toContain('is not in its current source');
expect(notFound.message).toContain(
'Its first line is found at line(s) 3'
);
const foundTwice = await launchWithEdits(generateEvents, {
edits: [{ old_string: 'const a = 1;', new_string: 'const a = 10;' }],
});
expect(foundTwice.success).toBe(false);
expect(foundTwice.message).toContain('found 2 times (at lines 1, 2)');
expect(generateEvents).not.toHaveBeenCalled();
const replacedTwice = await launchWithEdits(generateEvents, {
edits: [
{
old_string: 'const a = 1;',
new_string: 'const a = 10;',
replace_all: true,
},
{ old_string: 'const b = 2;', new_string: 'const b = 3;' },
],
});
expect(
(replacedTwice.editedEventSnippets || []).map(
({ replacements }) => replacements
)
).toEqual([
'Edit 1 replaced 2 occurrences (lines 1, 2). Edit 2 replaced 1 occurrence (line 3).',
]);
expect(getJsCode()).toBe(
['const a = 10;', 'const a = 10;', 'const b = 3;'].join('\n')
);
});
});
it('fails when an extension required by the generated events cannot be installed', async () => {
// $FlowFixMe[underconstrained-implicit-instantiation]
const ensureExtensionInstalled = jest.fn();
@@ -20,10 +20,12 @@ describe('processEditorFunctionCalls', () => {
functionCalls: Array<EditorFunctionCall>
) => {
// The runner takes the same collaborators as launchFunction options,
// without `args` and `PixiResourcesLoader` (it provides those itself).
// without `args` and `PixiResourcesLoader` (it provides those itself)
// and `isCalledFromScript` (only set by `run_script`).
const {
args,
PixiResourcesLoader,
isCalledFromScript,
...collaborators
} = makeFakeLaunchFunctionOptionsWithoutProject();
return {
@@ -0,0 +1,453 @@
// @flow
import {
findEventByIdOrGroupName,
formatNumberedLines,
renderEventSourceById,
} from '../EventsSheet/EventsTree/TextRenderer/EventScriptSourceView';
const gd: libGDevelop = global.gd;
// The generation API refuses larger event scripts (they are stored with the
// generated events, in an item of 400KB at most).
const MAX_EVENT_SCRIPT_LENGTH = 100000;
const SNIPPET_CONTEXT_LINES = 3;
const MAX_SNIPPET_LINES = 60;
const MAX_LISTED_LINE_NUMBERS = 5;
type EventSourceEdit = {|
oldString: string,
newString: string,
replaceAll: boolean,
|};
type EditedRange = {| start: number, length: number, editIndex: number |};
/**
* What `edits` apply to: the code of a `js` event (numbered like
* `jsCodeExcerpt`), the EventScript source of any other event.
*/
type EditableEventSource = {|
eventId: string,
text: string,
// The indentation of the event when it is read among the other events:
// a text copied from there has it on every line.
indentation: string,
// The code of a `js` event is changed directly: there is nothing to
// generate.
isJsCode: boolean,
toEventScript: (text: string) => string,
|};
export type PreparedEventSourceEdits =
| {| success: false, message: string |}
| {|
success: true,
eventId: string,
isJsCode: boolean,
eventScript: string,
editedText: string,
editedRanges: Array<EditedRange>,
replacementsSummary: string,
includeSubEvents: boolean,
|};
const getEditableEventSource = ({
eventsList,
eventIdOrGroupName,
includeSubEvents,
}: {|
eventsList: gdEventsList,
eventIdOrGroupName: string,
includeSubEvents: boolean,
|}): EditableEventSource | null => {
const foundEvent = findEventByIdOrGroupName({
eventsList,
eventIdOrGroupName,
});
const source = renderEventSourceById({
eventsList,
eventIdOrGroupName,
includeSubEvents,
});
if (!foundEvent || source === null) return null;
const eventId = `event-${foundEvent.path}`;
if (foundEvent.event.getType() !== 'BuiltinCommonInstructions::JsCode') {
return {
eventId,
text: source,
indentation: foundEvent.indentation,
isJsCode: false,
toEventScript: text => text,
};
}
// The source of a `js` event is its header, its code and the closing
// `"""` line (the code is not re-indented: the event is shown alone).
const sourceLines = source.split('\n');
const headerLine = sourceLines[0];
const closingLine = sourceLines[sourceLines.length - 1];
return {
eventId,
text: gd
.asJsCodeEvent(foundEvent.event)
.getInlineCode()
.replace(/\r\n/g, '\n'),
indentation: foundEvent.indentation,
isJsCode: true,
toEventScript: code =>
[headerLine, ...(code === '' ? [] : [code]), closingLine].join('\n'),
};
};
const getLineNumber = (text: string, index: number): number =>
text.slice(0, index).split('\n').length;
const removeIndentation = (text: string, indentation: string): string =>
text
.split('\n')
.map(line =>
line.startsWith(indentation) ? line.slice(indentation.length) : line
)
.join('\n');
const getOccurrenceIndexes = (
text: string,
searched: string
): Array<number> => {
const indexes = [];
let index = text.indexOf(searched);
while (index !== -1) {
indexes.push(index);
index = text.indexOf(searched, index + searched.length);
}
return indexes;
};
const listLineNumbers = (text: string, indexes: Array<number>): string =>
indexes
.slice(0, MAX_LISTED_LINE_NUMBERS)
.map(index => getLineNumber(text, index))
.join(', ') + (indexes.length > MAX_LISTED_LINE_NUMBERS ? ', ...' : '');
/**
* What each edit replaced, so that a too broad `old_string` (a `replace_all`
* matching more than intended) shows: "Edit 1 replaced 2 occurrences (lines
* 1, 2104)."
*/
const getReplacementsSummary = (
text: string,
editedRanges: Array<EditedRange>,
editsCount: number
): string => {
const sentences = [];
for (let editIndex = 0; editIndex < editsCount; editIndex++) {
const starts = editedRanges
.filter(range => range.editIndex === editIndex)
.map(range => range.start)
.sort((a, b) => a - b);
sentences.push(
`Edit ${editIndex + 1} replaced ${starts.length} occurrence${
starts.length > 1 ? 's' : ''
} (line${starts.length > 1 ? 's' : ''} ${listLineNumbers(text, starts)}).`
);
}
return sentences.join(' ');
};
const getNotFoundHints = (text: string, oldString: string): Array<string> => {
const hints = [];
if (/^\d+\t/.test(oldString)) {
hints.push(
'Remove the line numbers and tabs copied from a numbered excerpt.'
);
}
const firstLine = oldString
.split('\n')
.map(line => line.replace(/^\d+\t/, '').trim())
.find(line => line !== '');
const firstLineIndexes = firstLine
? getOccurrenceIndexes(text, firstLine)
: [];
if (firstLineIndexes.length > 0) {
hints.push(
`Its first line is found at line(s) ${listLineNumbers(
text,
firstLineIndexes
)}: what follows differs.`
);
}
hints.push(
'Read the event again with `read_events_source` (`event_ids`, and `search` or `js_from_line` for a large `js` event) and copy the text exactly.'
);
return hints;
};
/**
* Apply the edits one after the other, like a coding agent's exact string
* replacements: each `old_string` must be found exactly once (or set
* `replace_all`). A text copied from a read showing the event among others
* has its indentation: it is matched without it too.
*/
const applyEdits = ({
editableEventSource: { eventId, text: sourceText, indentation },
edits,
}: {|
editableEventSource: EditableEventSource,
edits: Array<EventSourceEdit>,
|}):
| {| success: false, message: string |}
| {| success: true, text: string, editedRanges: Array<EditedRange> |} => {
let text = sourceText;
const editedRanges: Array<EditedRange> = [];
for (let editIndex = 0; editIndex < edits.length; editIndex++) {
const editLabel = `Edit ${editIndex + 1} of ${eventId}`;
let { oldString, newString } = edits[editIndex];
if (oldString === '') {
return {
success: false,
message: `${editLabel}: \`old_string\` is empty. Give the exact text to replace.`,
};
}
let indexes = getOccurrenceIndexes(text, oldString);
if (indexes.length === 0 && indentation) {
const oldStringWithoutIndentation = removeIndentation(
oldString,
indentation
);
const indexesWithoutIndentation = getOccurrenceIndexes(
text,
oldStringWithoutIndentation
);
if (indexesWithoutIndentation.length > 0) {
oldString = oldStringWithoutIndentation;
newString = removeIndentation(newString, indentation);
indexes = indexesWithoutIndentation;
}
}
if (indexes.length === 0) {
return {
success: false,
message: [
`${editLabel}: \`old_string\` is not in its current source.`,
...getNotFoundHints(text, oldString),
].join(' '),
};
}
if (indexes.length > 1 && !edits[editIndex].replaceAll) {
return {
success: false,
message: `${editLabel}: \`old_string\` is found ${
indexes.length
} times (at lines ${listLineNumbers(
text,
indexes
)}). Add lines around it to make it unique, or set \`replace_all\`.`,
};
}
// Replace from the last occurrence, so the indexes before stay valid,
// and move the ranges edited before that follow a replaced text.
const lengthDifference = newString.length - oldString.length;
for (let i = indexes.length - 1; i >= 0; i--) {
const index = indexes[i];
text =
text.slice(0, index) + newString + text.slice(index + oldString.length);
for (const range of editedRanges) {
if (range.start > index) range.start += lengthDifference;
}
}
for (let i = 0; i < indexes.length; i++) {
editedRanges.push({
start: indexes[i] + i * lengthDifference,
length: newString.length,
editIndex,
});
}
}
return { success: true, text, editedRanges };
};
/**
* The `edits` of a batch of `generate_events` turned into the `event_script`
* replacing the event (null when the batch has none): the editor applies them
* to the current source of the event, so it is not resent in full.
*/
export const prepareEventSourceEdits = ({
eventsList,
batch,
batchLabel,
}: {|
eventsList: gdEventsList,
batch: any,
batchLabel: string,
|}): PreparedEventSourceEdits | null => {
if (!batch || !Array.isArray(batch.edits) || batch.edits.length === 0) {
return null;
}
const edits: Array<EventSourceEdit> = batch.edits.map(edit => ({
oldString:
edit && typeof edit.old_string === 'string' ? edit.old_string : '',
newString:
edit && typeof edit.new_string === 'string' ? edit.new_string : '',
replaceAll: !!edit && edit.replace_all === true,
}));
const placementRelation = batch.placement_relation;
const placementTargetEventId =
typeof batch.placement_target_event_id === 'string'
? batch.placement_target_event_id
: '';
const fail = (message: string): PreparedEventSourceEdits => ({
success: false,
message: `${batchLabel}${message}`,
});
if (typeof batch.event_script === 'string' && batch.event_script) {
return fail(
'Give either `edits` or `event_script`, not both: `edits` change the current source of the event, `event_script` replaces it.'
);
}
const includeSubEvents =
placementRelation === 'replace_entire_event_and_sub_events';
if (
!includeSubEvents &&
placementRelation !== 'replace_event_but_keep_existing_sub_events'
) {
return fail(
'`edits` change an existing event: use them with `replace_event_but_keep_existing_sub_events` (the event without its sub-events) or `replace_entire_event_and_sub_events` (the event with its sub-events).'
);
}
const editableEventSource = getEditableEventSource({
eventsList,
eventIdOrGroupName: placementTargetEventId,
includeSubEvents,
});
if (!editableEventSource) {
return fail(
`No event found for "${placementTargetEventId}" (\`placement_target_event_id\`).`
);
}
const editsResult = applyEdits({ editableEventSource, edits });
if (!editsResult.success) return fail(editsResult.message);
const eventScript = editableEventSource.toEventScript(editsResult.text);
if (
!editableEventSource.isJsCode &&
eventScript.length > MAX_EVENT_SCRIPT_LENGTH
) {
return fail(
`The edited ${editableEventSource.eventId} is too large to be sent (${
eventScript.length
} characters, at most ${MAX_EVENT_SCRIPT_LENGTH}).`
);
}
return {
success: true,
eventId: editableEventSource.eventId,
isJsCode: editableEventSource.isJsCode,
eventScript,
editedText: editsResult.text,
editedRanges: editsResult.editedRanges,
replacementsSummary: getReplacementsSummary(
editsResult.text,
editsResult.editedRanges,
edits.length
),
includeSubEvents,
};
};
/**
* An edit of `generate_events` as the lines it removes (`-`) and adds (`+`),
* to show it in the chat.
*/
export const formatEditAsDiff = (edit: any): string => {
const oldString =
edit && typeof edit.old_string === 'string' ? edit.old_string : '';
const newString =
edit && typeof edit.new_string === 'string' ? edit.new_string : '';
return [
...oldString.split('\n').map(line => `- ${line}`),
...newString.split('\n').map(line => `+ ${line}`),
...(edit && edit.replace_all === true ? ['(every occurrence)'] : []),
].join('\n');
};
/**
* Set the edited code on the `js` event: unlike the other events, nothing
* needs to be generated (so its size is not limited).
*/
export const applyJsCodeEdits = ({
eventsList,
preparedEdits: { eventId, editedText },
}: {|
eventsList: gdEventsList,
preparedEdits: { eventId: string, editedText: string, ... },
|}): void => {
const foundEvent = findEventByIdOrGroupName({
eventsList,
eventIdOrGroupName: eventId,
});
if (!foundEvent) return;
gd.asJsCodeEvent(foundEvent.event).setInlineCode(editedText);
};
/**
* The lines around the edits, numbered, in the source of the edited event
* as it is now in the events (the code of a `js` event, the EventScript
* source of any other event).
*/
export const renderEditedEventSnippet = ({
eventsList,
preparedEdits: { eventId, editedText, editedRanges, includeSubEvents },
}: {|
eventsList: gdEventsList,
preparedEdits: {
eventId: string,
editedText: string,
editedRanges: Array<EditedRange>,
includeSubEvents: boolean,
...
},
|}): string => {
const editableEventSource = getEditableEventSource({
eventsList,
eventIdOrGroupName: eventId,
includeSubEvents,
});
const lines = (editableEventSource
? editableEventSource.text
: editedText
).split('\n');
const lineRanges = editedRanges
.map(({ start, length }) => ({
from: Math.max(
0,
getLineNumber(editedText, start) - 1 - SNIPPET_CONTEXT_LINES
),
to: Math.min(
lines.length - 1,
getLineNumber(editedText, start + length) - 1 + SNIPPET_CONTEXT_LINES
),
}))
.sort((a, b) => a.from - b.from);
const snippetLines = [];
let lastShownIndex = -1;
for (const { from, to } of lineRanges) {
const blockStart = Math.max(from, lastShownIndex + 1);
if (blockStart > to) continue;
if (snippetLines.length + (to - blockStart + 1) > MAX_SNIPPET_LINES) {
snippetLines.push('-- (the lines around the next edits are not shown)');
break;
}
if (lastShownIndex !== -1 && blockStart > lastShownIndex + 1) {
snippetLines.push('--');
}
snippetLines.push(
...formatNumberedLines(lines.slice(blockStart, to + 1), blockStart)
);
lastShownIndex = to;
}
return snippetLines.join('\n');
};
@@ -81,11 +81,21 @@ describe('read_game_project_json', () => {
]);
});
it('fails with the available keys for an unknown path', async () => {
it('fails with the available keys, and the item read by its index, for an unknown path', async () => {
const result = await launch({ path: 'scenes[0].unknownKey' });
expect(result.success).toBe(false);
expect(result.message).toBe(
'Key "unknownKey" not found. Available keys: sceneName, objects, objectGroups, sceneVariables, layers, instancesOnSceneDescription.'
'Key "unknownKey" not found. Available keys: sceneName, objects, objectGroups, sceneVariables, layers, instancesOnSceneDescription. ' +
'scenes[0] is the item with sceneName "Level1". The items of scenes are: [0] "Level1", [1] "Level2", [2] "Menu". ' +
'To read one by its name: { path: "scenes", filter: { property: "sceneName", value: "..." } }.'
);
});
it('fails with only the available keys for an unknown key that is not in a list item', async () => {
const result = await launch({ path: 'unknownKey' });
expect(result.success).toBe(false);
expect(result.message).toMatch(
/^Key "unknownKey" not found\. Available keys: [^.]*\.$/
);
});
});
@@ -616,6 +616,116 @@ describe('scope of the events and variables functions', () => {
expect(result.eventScript).not.toBe(noEventsInSceneText);
});
describe('a `js` event larger than `max_chars`', () => {
const lines = Array.from(
{ length: 3000 },
(_, index) => `const value${index} = ${index};`
);
const readToast = (args: Object) =>
launch('read_events_source', {
scope: { type: 'extension', extension_name: 'UI' },
function_name: 'ShowToast',
...args,
});
beforeEach(() => {
const jsCodeEvent = project
.getEventsFunctionsExtension('UI')
.getEventsFunctions()
.getEventsFunction('ShowToast')
.getEvents()
.insertNewEvent(project, 'BuiltinCommonInstructions::JsCode', 0);
gd.asJsCodeEvent(jsCodeEvent).setInlineCode(lines.join('\n'));
});
it('is read by ranges of lines, following the notes', async () => {
const readLines: Array<string> = [];
let fromLine = 1;
let calls = 0;
while (fromLine <= lines.length && calls++ < 10) {
const result = await readToast({
max_chars: 30000,
js_from_line: fromLine,
});
const { jsCodeExcerpt } = result;
if (!jsCodeExcerpt) throw new Error('Expected a jsCodeExcerpt.');
expect(jsCodeExcerpt).toMatchObject({
eventId: 'event-0',
fromLine,
totalLines: lines.length,
});
expect(result.eventScript).not.toContain('value0');
const numberedLines = jsCodeExcerpt.code.split('\n');
const firstLineNumber = fromLine;
expect(
numberedLines.map(line => Number(line.split('\t')[0]))
).toEqual(
numberedLines.map((line, index) => firstLineNumber + index)
);
readLines.push(
...numberedLines.map(line => line.replace(/^\d+\t/, ''))
);
fromLine = jsCodeExcerpt.toLine + 1;
if (fromLine <= lines.length) {
expect(result.notes).toContainEqual(
expect.stringContaining(`\`js_from_line: ${fromLine}\``)
);
}
}
expect(calls).toBe(3);
expect(readLines).toEqual(lines);
});
it('is searched line by line, with the lines around the matches', async () => {
const result = await readToast({ search: 'value2999 =' });
expect(result.jsCodeExcerpt).toEqual({
eventId: 'event-0',
fromLine: 2997,
toLine: 3000,
totalLines: lines.length,
code: [
'2997\tconst value2996 = 2996;',
'2998\tconst value2997 = 2997;',
'2999\tconst value2998 = 2998;',
'3000\tconst value2999 = 2999;',
].join('\n'),
});
// Too many matches to fit: the note says where to continue.
const firstMatches = await readToast({ search: 'value1' });
const firstExcerpt = firstMatches.jsCodeExcerpt;
if (!firstExcerpt) throw new Error('Expected a jsCodeExcerpt.');
const nextLineMatch = (firstMatches.notes || [])
.join(' ')
.match(/`js_from_line: (\d+)`/);
if (!nextLineMatch) throw new Error('Expected a next js_from_line.');
const nextMatches = await readToast({
search: 'value1',
js_from_line: Number(nextLineMatch[1]),
});
const nextExcerpt = nextMatches.jsCodeExcerpt;
if (!nextExcerpt) throw new Error('Expected a jsCodeExcerpt.');
expect(nextExcerpt.fromLine).toBeGreaterThan(firstExcerpt.toLine - 4);
});
it('is read in full inside run_script', async () => {
const result = await launch('run_script', {
js_code: `
const inScript = await read_events_source({
scope: { type: 'extension', extension_name: 'UI' },
function_name: 'ShowToast',
max_chars: 100000,
});
console.log(inScript.eventScript.includes('const value2999 = 2999;'), !!inScript.jsCodeExcerpt);
`,
});
expect(result.consoleLogs).toEqual(['true false']);
const alone = await readToast({ max_chars: 100000 });
expect(alone.jsCodeExcerpt).toBeDefined();
});
});
it('fails when scene_name and scope disagree', async () => {
const result = await launch('read_events_source', {
scene_name: 'Level',
@@ -322,6 +322,59 @@ export const parsePath = (path: string): Array<PathStep> => {
return steps;
};
const MAX_LISTED_ITEM_NAMES = 20;
/** The key holding the name of the items of a list (`extensionName`, `name`...). */
const findItemNameKey = (item: any): ?string =>
item && typeof item === 'object' && !Array.isArray(item)
? Object.keys(item).find(
key => /^(name|[a-z]+Name)$/.test(key) && typeof item[key] === 'string'
)
: null;
const renderPathSteps = (steps: Array<PathStep>): string =>
steps
.map((step, index) =>
step.type === 'key'
? `${index === 0 ? '' : '.'}${step.key}`
: step.type === 'index'
? `[${step.index}]`
: '[*]'
)
.join('');
/**
* For a key missing in an item reached by its index in a list of named items
* (`extensions[0]`): which item it is, the other items, and how to read one
* by its name - reading the wrong item by index looks like "this key does
* not exist" otherwise.
*/
const describeIndexedItem = ({
items,
itemIndex,
listPath,
}: {|
items: Array<any>,
itemIndex: number,
listPath: string,
|}): string => {
const nameKey = findItemNameKey(items[itemIndex]);
if (!nameKey) return '';
const listedNames = items
.slice(0, MAX_LISTED_ITEM_NAMES)
.map((item, index) =>
item && typeof item[nameKey] === 'string'
? `[${index}] "${item[nameKey]}"`
: `[${index}]`
);
const notListedCount = items.length - listedNames.length;
return ` ${listPath}[${itemIndex}] is the item with ${nameKey} "${
items[itemIndex][nameKey]
}". The items of ${listPath} are: ${listedNames.join(', ')}${
notListedCount > 0 ? ` (and ${notListedCount} more)` : ''
}. To read one by its name: { path: "${listPath}", filter: { property: "${nameKey}", value: "..." } }.`;
};
/**
* Walk a value along pre-parsed path steps, applying an optional array
* filter and depth-truncation on the result.
@@ -342,6 +395,9 @@ const walkSteps = ({
| {| success: true, result: any |}
| {| success: false, message: string |} => {
let value: any = current;
// The list the last `[index]` step went into, to say which item a missing
// key was looked for in.
let lastIndexedList: Array<any> | null = null;
for (let i = startIndex; i < steps.length; i++) {
if (value === null || value === undefined) {
@@ -365,11 +421,20 @@ const walkSteps = ({
// $FlowFixMe[method-unbinding] - deliberate `.call` on a foreign object.
if (!Object.prototype.hasOwnProperty.call(value, step.key)) {
const availableKeys = Object.keys(value);
const previousStep = steps[i - 1];
return {
success: false,
message: `Key "${step.key}" not found. Available keys: ${
availableKeys.length ? availableKeys.join(', ') : '(none)'
}.`,
}.${
lastIndexedList && previousStep && previousStep.type === 'index'
? describeIndexedItem({
items: lastIndexedList,
itemIndex: previousStep.index,
listPath: renderPathSteps(steps.slice(0, i - 1)),
})
: ''
}`,
};
}
value = value[step.key];
@@ -390,6 +455,7 @@ const walkSteps = ({
} items).`,
};
}
lastIndexedList = value;
value = value[step.index];
} else {
// Wildcard [*]
@@ -158,7 +158,9 @@ describe('navigateSimplifiedProjectJson', () => {
expect(result.success).toBe(false);
if (!result.success) {
expect(result.message).toBe(
'Key "name" not found. Available keys: sceneName, objects.'
'Key "name" not found. Available keys: sceneName, objects. ' +
'scenes[0] is the item with sceneName "Home". The items of scenes are: [0] "Home", [1] "Level1", [2] "Level2", [3] "BossLevel", [4] "Level3". ' +
'To read one by its name: { path: "scenes", filter: { property: "sceneName", value: "..." } }.'
);
}
});
+169 -12
View File
@@ -18,11 +18,19 @@ import {
eventsTextRenderingErrorText,
type EventsTextRenderingError,
} from '../EventsSheet/EventsTree/TextRenderer';
import {
applyJsCodeEdits,
formatEditAsDiff,
prepareEventSourceEdits,
renderEditedEventSnippet,
type PreparedEventSourceEdits,
} from './EventSourceEdits';
import {
buildEventScriptSourceView,
renderEventSourceById,
renderScopeSummaryHeaderLines,
type ScopeSummary,
type JsCodeExcerpt,
} from '../EventsSheet/EventsTree/TextRenderer/EventScriptSourceView';
import {
addMissingObjectBehaviors,
@@ -337,6 +345,13 @@ export type EditorFunctionGenericOutput = {|
// EventScript source view (see `read_events_source`):
eventScript?: string,
selectedEventIds?: Array<string>,
jsCodeExcerpt?: JsCodeExcerpt,
// `generate_events` with `edits`: the numbered lines around the edits.
editedEventSnippets?: Array<{|
eventId: string,
replacements: string,
snippet: string,
|}>,
truncated?: boolean,
notes?: Array<string>,
generatedEventsErrorDiagnostics?: string,
@@ -428,6 +443,9 @@ export type EventBatch = {|
// The actual current source of the target event, that the backend
// compares the anchor against:
placementTargetEventSource: string | null,
// `eventScript` was built from `edits` (its compile errors are always
// reported):
isEventScriptFromEdits: boolean,
|};
export type EventsGenerationOptions = {|
@@ -558,6 +576,9 @@ export type LaunchFunctionOptionsWithoutProject = {|
// When true, `run_script` exposes only non-mutating functions (explorer
// sub-agent scripts, which must stay read-only). Ignored by other functions.
runScriptReadOnly?: boolean,
// True for a function called by a `run_script` script: its output stays in
// the script (only its logs reach the AI), so it can be larger.
+isCalledFromScript?: boolean,
i18n: I18nType,
relatedAiRequestId: string | null,
getRelatedAiRequestLastMessages: () => RelatedAiRequestLastMessages,
@@ -6500,6 +6521,7 @@ export const noEventsInFunctionText = 'This function has no events.';
const EVENTS_SOURCE_MAX_CHARS_DEFAULT = 12000;
const EVENTS_SOURCE_MAX_CHARS_MINIMUM = 2000;
const EVENTS_SOURCE_MAX_CHARS_LIMIT = 30000;
const EVENTS_SOURCE_MAX_CHARS_LIMIT_IN_SCRIPT = 1000000;
const getPropertyNames = (
propertiesContainer: gdPropertiesContainer
@@ -6690,7 +6712,12 @@ const readEventsSource: EditorFunction = {
return { text };
},
launchFunction: async ({ project, args, ensureExtensionsUpToDate }) => {
launchFunction: async ({
project,
args,
ensureExtensionsUpToDate,
isCalledFromScript,
}) => {
const resolvedScope = resolveScopeFromArgs(project, args, {
allowedTypes: ['scene', 'extension', 'custom_behavior', 'custom_object'],
});
@@ -6730,10 +6757,16 @@ const readEventsSource: EditorFunction = {
const maxChars = Math.max(
EVENTS_SOURCE_MAX_CHARS_MINIMUM,
Math.min(
EVENTS_SOURCE_MAX_CHARS_LIMIT,
isCalledFromScript
? EVENTS_SOURCE_MAX_CHARS_LIMIT_IN_SCRIPT
: EVENTS_SOURCE_MAX_CHARS_LIMIT,
maxCharsArgument || EVENTS_SOURCE_MAX_CHARS_DEFAULT
)
);
const jsFromLine = SafeExtractor.extractNumberProperty(
args,
'js_from_line'
);
// In a function, what the events can use (parameters, properties, child
// objects, extension variables) is shown as `#` comment lines at the top
@@ -6750,6 +6783,7 @@ const readEventsSource: EditorFunction = {
const {
text,
selectedEventIds,
jsCodeExcerpt,
truncated,
notes,
renderingErrors,
@@ -6759,6 +6793,7 @@ const readEventsSource: EditorFunction = {
searchText,
objectNames,
subEventsDepth,
jsFromLine,
maxChars: Math.max(
0,
maxChars -
@@ -6792,6 +6827,7 @@ const readEventsSource: EditorFunction = {
: eventScriptText,
selectedEventIds,
};
if (jsCodeExcerpt) output.jsCodeExcerpt = jsCodeExcerpt;
if (truncated) output.truncated = true;
if (notes.length > 0) output.notes = notes;
if (renderingErrors.length > 0) {
@@ -6857,6 +6893,8 @@ const addSceneEvents: EditorFunction = {
batch,
'event_script'
);
const edits =
SafeExtractor.extractArrayProperty(batch, 'edits') || [];
const placementRelation = SafeExtractor.extractStringProperty(
batch,
'placement_relation'
@@ -6904,6 +6942,21 @@ const addSceneEvents: EditorFunction = {
: {eventScript}
</Text>
)}
{edits.map((edit, index) => (
<Text
key={index}
noMargin
allowSelection
color="secondary"
size="body-small"
style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}
>
<b>
<Trans>Edit</Trans>
</b>
{`:\n${formatEditAsDiff(edit)}`}
</Text>
))}
{placementRelation && (
<Text
noMargin
@@ -7231,8 +7284,86 @@ const addSceneEvents: EditorFunction = {
? serializeToJSON(currentEventsList)
: null;
// `edits` are applied here, to the current source of the event: the
// batch then replaces the event with the edited source, like an
// `event_script` would.
const preparedEventSourceEdits: Array<PreparedEventSourceEdits | null> = (
eventBatches || []
).map((batch, index) =>
prepareEventSourceEdits({
eventsList: currentEventsList,
batch,
batchLabel:
eventBatches && eventBatches.length > 1 ? `Batch ${index + 1}: ` : '',
})
);
const editsFailureMessages = [];
for (const preparedEdits of preparedEventSourceEdits) {
if (preparedEdits && preparedEdits.success === false) {
editsFailureMessages.push(preparedEdits.message);
}
}
if (editsFailureMessages.length > 0) {
return makeGenericFailure(
`${editsFailureMessages.join('\n')}\nNothing was changed.`
);
}
// The code of `js` events is changed directly (nothing to generate):
// such a call must only change `js` events, so that it stays atomic.
const jsCodeEdits = [];
for (const preparedEdits of preparedEventSourceEdits) {
if (preparedEdits && preparedEdits.success && preparedEdits.isJsCode) {
jsCodeEdits.push(preparedEdits);
}
}
if (jsCodeEdits.length > 0) {
if (jsCodeEdits.length !== preparedEventSourceEdits.length) {
return makeGenericFailure(
'The `edits` of a `js` event are applied directly, without generating events: give them in a `generate_events` call of their own, apart from the other batches. Nothing was changed.'
);
}
for (const preparedEdits of jsCodeEdits) {
applyJsCodeEdits({ eventsList: currentEventsList, preparedEdits });
}
if (eventsFunction) {
const extensionName = resolvedScope.eventsFunctionsExtension
? resolvedScope.eventsFunctionsExtension.getName()
: '';
onSceneEventsModifiedOutsideEditor({
scene: null,
eventsFunction,
extensionName,
newOrChangedAiGeneratedEventIds: new Set(),
});
onExtensionsModifiedOutsideEditor({
extensionNames: [extensionName],
needsCodeRegeneration: true,
});
} else {
onSceneEventsModifiedOutsideEditor({
scene,
newOrChangedAiGeneratedEventIds: new Set(),
});
}
return {
success: true,
message: `Changed the code of the \`js\` event(s) ${jsCodeEdits
.map(preparedEdits => preparedEdits.eventId)
.join(', ')}.`,
editedEventSnippets: jsCodeEdits.map(preparedEdits => ({
eventId: preparedEdits.eventId,
replacements: preparedEdits.replacementsSummary,
snippet: renderEditedEventSnippet({
eventsList: currentEventsList,
preparedEdits,
}),
})),
};
}
const parsedEventBatches = eventBatches
? eventBatches.map(batch => {
? eventBatches.map((batch, index) => {
const placementRelation =
SafeExtractor.extractStringProperty(batch, 'placement_relation') ||
'(unspecified)';
@@ -7271,16 +7402,21 @@ const addSceneEvents: EditorFunction = {
? renderedTargetEventSource
: null;
const preparedEdits = preparedEventSourceEdits[index];
const editedEventScript =
preparedEdits && preparedEdits.success
? preparedEdits.eventScript
: null;
return {
eventsDescription:
SafeExtractor.extractStringProperty(
batch,
'events_description'
) || '',
eventScript: SafeExtractor.extractStringProperty(
batch,
'event_script'
),
eventScript:
editedEventScript ||
SafeExtractor.extractStringProperty(batch, 'event_script'),
placementRelation,
placementTargetEventId,
placementExpectedParentEventId: SafeExtractor.extractStringProperty(
@@ -7291,11 +7427,16 @@ const addSceneEvents: EditorFunction = {
batch,
'placement_rationale'
),
expectedEventSource: SafeExtractor.extractStringProperty(
batch,
'expected_event_source'
),
// The edits were applied to the current source of the event: it
// is what was read.
expectedEventSource: editedEventScript
? placementTargetEventSource
: SafeExtractor.extractStringProperty(
batch,
'expected_event_source'
),
placementTargetEventSource,
isEventScriptFromEdits: !!editedEventScript,
};
})
: null;
@@ -7612,6 +7753,22 @@ See errors; verify event contents if needed.`
if (newlyAddedResources.length > 0) {
output.newlyAddedResources = newlyAddedResources;
}
const editedEventSnippets = [];
for (const preparedEdits of preparedEventSourceEdits) {
if (preparedEdits && preparedEdits.success) {
editedEventSnippets.push({
eventId: preparedEdits.eventId,
replacements: preparedEdits.replacementsSummary,
snippet: renderEditedEventSnippet({
eventsList: upToDateEventsList,
preparedEdits,
}),
});
}
}
if (editedEventSnippets.length > 0) {
output.editedEventSnippets = editedEventSnippets;
}
if (errors.length > 0) {
output.errors = errors;
}
@@ -11468,7 +11625,7 @@ const runScript: EditorFunction = {
const exposedFunctions = buildExposedScriptFunctions({
editorFunctions,
editorFunctionsWithoutProject,
launchOptions,
launchOptions: { ...launchOptions, isCalledFromScript: true },
project,
allowedFunctionNames,
});
@@ -22,9 +22,20 @@ type EventNode = {|
* ancestors of selected events shown as header lines and skipped siblings
* shown as `# ...` markers).
*/
export type JsCodeExcerpt = {|
eventId: string,
fromLine: number,
toLine: number,
totalLines: number,
code: string,
|};
export type EventScriptSourceView = {|
text: string,
selectedEventIds: Array<string>,
// A `js` event too large for `maxChars`: a range of lines of its code,
// outside `text` (which stays valid EventScript).
jsCodeExcerpt: JsCodeExcerpt | null,
truncated: boolean,
notes: Array<string>,
renderingErrors: Array<EventScriptRenderingError>,
@@ -151,6 +162,239 @@ const truncateEventScriptTextWithoutCuttingFences = (
return keptLines.join('\n');
};
const MIN_JS_CODE_EXCERPT_CHARS = 1000;
const JS_SEARCH_CONTEXT_LINES = 3;
const MAX_JS_SEARCH_LINE_LENGTH = 500;
/**
* Lines prefixed with their number and a tab, like `cat -n`: the format coding
* agents read code in. `firstIndex` is the index of the first line (from 0).
*/
export const formatNumberedLines = (
lines: Array<string>,
firstIndex: number
): Array<string> =>
lines.map((line, index) => `${firstIndex + index + 1}\t${line}`);
const numberedLinesNote =
'Its lines are numbered: the number and the tab before each line are not part of the code (never copy them into `old_string`).';
/**
* The lines of the code of a `js` event from `fromLine` that fit in
* `maxChars` (only the start of the first one when it is longer, like in
* minified code).
*/
const getJsCodeRangeExcerpt = ({
lines,
firstIndex,
eventId,
maxChars,
notePrefix,
}: {|
lines: Array<string>,
firstIndex: number,
eventId: string,
maxChars: number,
notePrefix: string,
|}): {| jsCodeExcerpt: JsCodeExcerpt, note: string |} => {
const keptLines: Array<string> = [];
let keptLength = 0;
for (let index = firstIndex; index < lines.length; index++) {
const numberedLine = `${index + 1}\t${lines[index]}`;
if (keptLength + numberedLine.length + 1 > maxChars) break;
keptLines.push(numberedLine);
keptLength += numberedLine.length + 1;
}
const isLineCut = keptLines.length === 0;
if (isLineCut) {
keptLines.push(
`${firstIndex + 1}\t${lines[firstIndex].slice(0, maxChars)}`
);
}
const toLine = firstIndex + keptLines.length;
const note = [
`${notePrefix}lines ${firstIndex +
1}-${toLine} of its code are in \`jsCodeExcerpt\` (read-only, not EventScript).`,
numberedLinesNote,
isLineCut
? `Line ${toLine} is longer than \`max_chars\`: only its start is shown.`
: null,
toLine < lines.length
? `Call again with \`js_from_line: ${toLine +
1}\` to read the next lines.`
: null,
]
.filter(Boolean)
.join(' ');
return {
jsCodeExcerpt: {
eventId,
fromLine: firstIndex + 1,
toLine,
totalLines: lines.length,
code: keptLines.join('\n'),
},
note,
};
};
/**
* The lines of the code of a `js` event containing `searchText` (from
* `firstIndex`), with a few lines around them, like `grep -n -C`: the
* separate blocks are separated by a `--` line.
*/
const getJsCodeSearchExcerpt = ({
lines,
firstIndex,
matchingIndexes,
searchText,
eventId,
maxChars,
notePrefix,
}: {|
lines: Array<string>,
firstIndex: number,
matchingIndexes: Array<number>,
searchText: string,
eventId: string,
maxChars: number,
notePrefix: string,
|}): {| jsCodeExcerpt: JsCodeExcerpt, note: string |} => {
const keptLines: Array<string> = [];
let keptLength = 0;
let firstShownIndex = matchingIndexes[0];
let lastShownIndex = -1;
let firstNotShownMatchIndex = null;
for (const matchIndex of matchingIndexes) {
if (matchIndex <= lastShownIndex) continue;
const blockStart = Math.max(
firstIndex,
matchIndex - JS_SEARCH_CONTEXT_LINES,
lastShownIndex + 1
);
const blockEnd = Math.min(
lines.length - 1,
matchIndex + JS_SEARCH_CONTEXT_LINES
);
const blockLines = [
...(lastShownIndex !== -1 && blockStart > lastShownIndex + 1
? ['--']
: []),
...formatNumberedLines(
lines
.slice(blockStart, blockEnd + 1)
.map(line =>
line.length > MAX_JS_SEARCH_LINE_LENGTH
? `${line.slice(0, MAX_JS_SEARCH_LINE_LENGTH)}…`
: line
),
blockStart
),
];
const blockLength = blockLines.join('\n').length + 1;
if (keptLines.length > 0 && keptLength + blockLength > maxChars) {
firstNotShownMatchIndex = matchIndex;
break;
}
if (lastShownIndex === -1) firstShownIndex = blockStart;
keptLines.push(...blockLines);
keptLength += blockLength;
lastShownIndex = blockEnd;
}
const note = [
`${notePrefix}${
matchingIndexes.length
} line(s) of its code from line ${firstIndex +
1} contain "${searchText}": they are in \`jsCodeExcerpt\` with up to ${JS_SEARCH_CONTEXT_LINES} lines around them (read-only, not EventScript).`,
numberedLinesNote,
firstNotShownMatchIndex !== null
? `Not all of them fit: call again with \`js_from_line: ${firstNotShownMatchIndex +
1}\` (and the same \`search\`) for the next ones.`
: null,
'Read a range of lines with `js_from_line` and no `search`.',
]
.filter(Boolean)
.join(' ');
return {
jsCodeExcerpt: {
eventId,
fromLine: firstShownIndex + 1,
toLine: lastShownIndex + 1,
totalLines: lines.length,
code: keptLines.join('\n'),
},
note,
};
};
/**
* A part of the code of a `js` event too large to be shown: the lines
* containing `searchText` when given, a range of lines otherwise.
*/
const getJsCodeExcerpt = ({
event,
path,
fromLine,
maxChars,
searchText,
}: {|
event: gdBaseEvent,
path: string,
fromLine: number,
maxChars: number,
searchText: ?string,
|}): {| jsCodeExcerpt: JsCodeExcerpt, note: string |} => {
const lines = gd
.asJsCodeEvent(event)
.getInlineCode()
.replace(/\r\n/g, '\n')
.split('\n');
const firstIndex = Math.min(Math.max(1, fromLine), lines.length) - 1;
const eventId = `event-${path}`;
const notePrefix = `Output too large: ${eventId} is a \`js\` event larger than \`max_chars\` (${
lines.length
} lines): `;
const trimmedSearchText = searchText ? searchText.trim() : '';
if (!trimmedSearchText) {
return getJsCodeRangeExcerpt({
lines,
firstIndex,
eventId,
maxChars,
notePrefix,
});
}
const lowerCaseSearchText = trimmedSearchText.toLowerCase();
const matchingIndexes = [];
for (let index = firstIndex; index < lines.length; index++) {
if (lines[index].toLowerCase().includes(lowerCaseSearchText)) {
matchingIndexes.push(index);
}
}
if (matchingIndexes.length === 0) {
return getJsCodeRangeExcerpt({
lines,
firstIndex,
eventId,
maxChars,
notePrefix: `${notePrefix}no line of its code from line ${firstIndex +
1} contains "${trimmedSearchText}", `,
});
}
return getJsCodeSearchExcerpt({
lines,
firstIndex,
matchingIndexes,
searchText: trimmedSearchText,
eventId,
maxChars,
notePrefix,
});
};
const compareDocumentOrder = (pathA: string, pathB: string): number => {
const partsA = pathA.split('.').map(Number);
const partsB = pathB.split('.').map(Number);
@@ -205,6 +449,32 @@ export const renderEventSourceById = ({
eventIdOrGroupName: string,
includeSubEvents: boolean,
|}): string | null => {
const node = findEventByIdOrGroupName({ eventsList, eventIdOrGroupName });
if (!node) return null;
if (!includeSubEvents) {
return renderEventOwnTextForMatching(node.event, node.path);
}
const renderingErrors: Array<EventScriptRenderingError> = [];
return renderEventAsEventScriptLines({
event: node.event,
eventPath: node.path,
indent: '',
subEventsDepth: Number.MAX_SAFE_INTEGER,
renderingErrors,
}).join('\n');
};
/**
* The event of an id (`event-2.1`) or group name, with its path (`2.1`) and
* the indentation its lines have when it is shown among the other events.
*/
export const findEventByIdOrGroupName = ({
eventsList,
eventIdOrGroupName,
}: {|
eventsList: gdEventsList,
eventIdOrGroupName: string,
|}): {| event: gdBaseEvent, path: string, indentation: string |} | null => {
const nodesByPath: Map<string, EventNode> = new Map();
indexEvents(eventsList, '', nodesByPath);
const paths = resolveEventIdOrGroupName(eventIdOrGroupName, nodesByPath);
@@ -212,17 +482,11 @@ export const renderEventSourceById = ({
if (firstPath === undefined) return null;
const node = nodesByPath.get(firstPath);
if (!node) return null;
if (!includeSubEvents) {
return renderEventOwnTextForMatching(node.event, firstPath);
}
const renderingErrors: Array<EventScriptRenderingError> = [];
return renderEventAsEventScriptLines({
return {
event: node.event,
eventPath: firstPath,
indent: '',
subEventsDepth: Number.MAX_SAFE_INTEGER,
renderingErrors,
}).join('\n');
path: firstPath,
indentation: INDENT.repeat(firstPath.split('.').length - 1),
};
};
/**
@@ -374,6 +638,7 @@ export const buildEventScriptSourceView = ({
objectNames,
subEventsDepth,
maxChars,
jsFromLine,
}: {|
eventsList: gdEventsList,
eventIds?: Array<string> | null,
@@ -381,6 +646,7 @@ export const buildEventScriptSourceView = ({
objectNames?: Array<string> | null,
subEventsDepth?: number | null,
maxChars: number,
jsFromLine?: number | null,
|}): EventScriptSourceView => {
const notes: Array<string> = [];
const renderingErrors: Array<EventScriptRenderingError> = [];
@@ -481,6 +747,7 @@ export const buildEventScriptSourceView = ({
return {
text: '',
selectedEventIds: [],
jsCodeExcerpt: null,
truncated: false,
notes: [...notes, emptyResultNote],
renderingErrors,
@@ -540,6 +807,7 @@ export const buildEventScriptSourceView = ({
// `selectedEventIds` always describes what the returned text actually
// shows: it is recomputed when trailing events are dropped below.
let shownPaths = selectedPaths;
let jsCodeExcerpt = null;
let text = '';
let truncated = false;
let fitted = false;
@@ -585,12 +853,32 @@ export const buildEventScriptSourceView = ({
'Output too large: the source of the last event shown is cut short. Read it alone with `event_ids` (or raise `max_chars`) to get it in full.'
);
} else {
// A `js` event bigger than the budget is dropped whole (its code
// cannot be cut in half): say so, and do not claim it is shown.
// A `js` event bigger than the budget is not in `text` (half a fence
// is not valid EventScript): a range of lines of its code is given
// aside instead.
keptPaths.pop();
notes.push(
`Output too large: event-${lastPath} is not shown at all (its source is larger than \`max_chars\`, and a \`js\` event cannot be cut in half). Raise \`max_chars\` to read it.`
);
const lastNode = nodesByPath.get(lastPath);
if (
lastNode &&
lastNode.event.getType() === 'BuiltinCommonInstructions::JsCode'
) {
const excerpt = getJsCodeExcerpt({
event: lastNode.event,
path: lastPath,
fromLine: jsFromLine || 1,
searchText,
maxChars: Math.max(
MIN_JS_CODE_EXCERPT_CHARS,
maxChars - text.length
),
});
jsCodeExcerpt = excerpt.jsCodeExcerpt;
notes.push(excerpt.note);
} else {
notes.push(
`Output too large: event-${lastPath} is not shown at all (its source is larger than \`max_chars\`). Raise \`max_chars\` to read it.`
);
}
}
}
if (droppedPaths.length > 0) {
@@ -620,6 +908,7 @@ export const buildEventScriptSourceView = ({
return {
text,
selectedEventIds: shownPaths.map(path => `event-${path}`),
jsCodeExcerpt,
truncated,
notes,
renderingErrors,
@@ -552,18 +552,52 @@ describe('EventScriptSourceView', () => {
'\n'
)
);
// Nothing of the event is shown: the note says so (reading it alone
// again would not help, only a bigger budget does).
// Its code is given aside instead, as a range of lines.
expect(jsOnlyView.selectedEventIds).toEqual([]);
expect(jsOnlyView.notes.join(' ')).toContain(
'event-1 is not shown at all'
);
expect(jsOnlyView.jsCodeExcerpt).toEqual({
eventId: 'event-1',
fromLine: 1,
toLine: 20,
totalLines: 20,
code: longCode
.split('\n')
.map((line, index) => `${index + 1}\t${line}`)
.join('\n'),
});
expect(jsOnlyView.notes.join(' ')).toContain('`jsCodeExcerpt`');
expect(jsOnlyView.notes.join(' ')).not.toContain('cut short');
} finally {
project.delete();
}
});
it('gives the start of a line of a `js` event longer than the budget', () => {
const { project } = makeTestProject(gd);
try {
const minifiedLine = 'x=1;'.repeat(2000);
const eventsList = makeEventsList(project, [
{
type: 'BuiltinCommonInstructions::JsCode',
inlineCode: [minifiedLine, 'done();'].join('\n'),
parameterObjects: '',
useStrict: true,
eventsSheetExpanded: false,
},
]);
const view = buildEventScriptSourceView({ eventsList, maxChars: 2000 });
const { jsCodeExcerpt } = view;
if (!jsCodeExcerpt) throw new Error('Expected a jsCodeExcerpt.');
expect(jsCodeExcerpt.toLine).toBe(1);
expect(jsCodeExcerpt.code.startsWith('1\tx=1;x=1;')).toBe(true);
expect(jsCodeExcerpt.code.length).toBeLessThanOrEqual(2000);
expect(view.notes.join(' ')).toContain('only its start is shown');
expect(view.notes.join(' ')).toContain('`js_from_line: 2`');
} finally {
project.delete();
}
});
it('keeps a `js` event whole when it fits, dropping only what follows', () => {
const { project } = makeTestProject(gd);
try {
@@ -252,6 +252,9 @@ export type AiGeneratedEventBatch = {
// 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 = {