mirror of
https://github.com/4ian/GDevelop.git
synced 2026-10-12 02:01:17 +00:00
Allow edition by the agent of large JS code blocks (#9181)
This commit is contained in:
@@ -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: "..." } }.'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user