diff --git a/.husky/pre-commit b/.husky/pre-commit index 98c3a700a..397072929 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,6 +1,3 @@ -#!/usr/bin/env sh -. "$(dirname -- "$0")/_/husky.sh" - # List staged files only. fileList=$(git diff --diff-filter=AM --cached --name-only) diff --git a/package.json b/package.json index 980aec482..2a35a4935 100644 --- a/package.json +++ b/package.json @@ -282,6 +282,10 @@ "dark": "images/dark/play.svg" } }, + { + "command": "mdb.exportCodeToPlayground", + "title": "Export Code to Playground" + }, { "command": "mdb.exportToPython", "title": "MongoDB: Export To Python 3" @@ -747,6 +751,17 @@ "when": "mdb.isPlayground == true" } ], + "mdb.copilot": [ + { + "command": "mdb.exportCodeToPlayground" + } + ], + "editor/context": [ + { + "submenu": "mdb.copilot", + "group": "1_main@2" + } + ], "commandPalette": [ { "command": "mdb.selectDatabaseWithParticipant", @@ -948,6 +963,10 @@ "command": "mdb.runPlayground", "when": "false" }, + { + "command": "mdb.exportCodeToPlayground", + "when": "false" + }, { "command": "mdb.createIndexFromTreeView", "when": "false" @@ -994,6 +1013,12 @@ } ] }, + "submenus": [ + { + "id": "mdb.copilot", + "label": "MongoDB Copilot Participant" + } + ], "keybindings": [ { "command": "mdb.runSelectedPlaygroundBlocks", diff --git a/src/commands/index.ts b/src/commands/index.ts index c36c477d2..209990b10 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -15,6 +15,7 @@ enum EXTENSION_COMMANDS { MDB_RUN_SELECTED_PLAYGROUND_BLOCKS = 'mdb.runSelectedPlaygroundBlocks', MDB_RUN_ALL_PLAYGROUND_BLOCKS = 'mdb.runAllPlaygroundBlocks', MDB_RUN_ALL_OR_SELECTED_PLAYGROUND_BLOCKS = 'mdb.runPlayground', + MDB_EXPORT_CODE_TO_PLAYGROUND = 'mdb.exportCodeToPlayground', MDB_FIX_THIS_INVALID_INTERACTIVE_SYNTAX = 'mdb.fixThisInvalidInteractiveSyntax', MDB_FIX_ALL_INVALID_INTERACTIVE_SYNTAX = 'mdb.fixAllInvalidInteractiveSyntax', diff --git a/src/editors/playgroundController.ts b/src/editors/playgroundController.ts index 118a7a376..0e2a6914b 100644 --- a/src/editors/playgroundController.ts +++ b/src/editors/playgroundController.ts @@ -445,13 +445,16 @@ export default class PlaygroundController { return this._createPlaygroundFileWithContent(content); } - async _evaluate({ - codeToEvaluate, - filePath, - }: { - codeToEvaluate: string; - filePath?: string; - }): Promise { + async _evaluate( + { + codeToEvaluate, + filePath, + }: { + codeToEvaluate: string; + filePath?: string; + }, + token: vscode.CancellationToken + ): Promise { const connectionId = this._connectionController.getActiveConnectionId(); if (!connectionId) { @@ -460,14 +463,17 @@ export default class PlaygroundController { this._statusView.showMessage('Getting results...'); - let result: ShellEvaluateResult; + let result: ShellEvaluateResult = null; try { // Send a request to the language server to execute scripts from a playground. - result = await this._languageServerController.evaluate({ - codeToEvaluate, - connectionId, - filePath, - }); + result = await this._languageServerController.evaluate( + { + codeToEvaluate, + connectionId, + filePath, + }, + token + ); } catch (error) { const msg = 'An internal error has occurred. The playground services have been restored. This can occur when the playground runner runs out of memory.'; @@ -504,37 +510,22 @@ export default class PlaygroundController { throw new Error(connectBeforeRunningMessage); } - try { - const progressResult = await vscode.window.withProgress( - { - location: ProgressLocation.Notification, - title: 'Running MongoDB playground...', - cancellable: true, - }, - async (progress, token) => { - token.onCancellationRequested(() => { - // If a user clicked the cancel button terminate all playground scripts. - this._languageServerController.cancelAll(); - - return { result: undefined }; - }); - - // Run all playground scripts. - const result: ShellEvaluateResult = await this._evaluate({ + return await vscode.window.withProgress( + { + location: ProgressLocation.Notification, + title: 'Running MongoDB playground...', + cancellable: true, + }, + (progress, token): Promise => { + return this._evaluate( + { codeToEvaluate, filePath, - }); - - return result; - } - ); - - return progressResult; - } catch (error) { - log.error('Evaluating playground with cancel modal failed', error); - - return { result: undefined }; - } + }, + token + ); + } + ); } async _openInResultPane(result: PlaygroundResult): Promise { @@ -923,7 +914,7 @@ export default class PlaygroundController { language, num_stages: selectedText ? countAggregationStagesInString(selectedText) - : undefined, + : null, with_import_statements: importStatements, with_builders: builders, with_driver_syntax: driverSyntax, diff --git a/src/language/languageServerController.ts b/src/language/languageServerController.ts index 13c579e09..cd95afb7b 100644 --- a/src/language/languageServerController.ts +++ b/src/language/languageServerController.ts @@ -5,11 +5,7 @@ import type { LanguageClientOptions, ServerOptions, } from 'vscode-languageclient/node'; -import { - LanguageClient, - TransportKind, - CancellationTokenSource, -} from 'vscode-languageclient/node'; +import { LanguageClient, TransportKind } from 'vscode-languageclient/node'; import type { ExtensionContext } from 'vscode'; import { workspace } from 'vscode'; import util from 'util'; @@ -32,8 +28,6 @@ const log = createLogger('language server controller'); */ export default class LanguageServerController { _context: ExtensionContext; - _source?: CancellationTokenSource; - _isExecutingInProgress = false; _client: LanguageClient; _currentConnectionId: string | null = null; _currentConnectionString?: string; @@ -182,32 +176,25 @@ export default class LanguageServerController { } async evaluate( - playgroundExecuteParameters: PlaygroundEvaluateParams + playgroundExecuteParameters: PlaygroundEvaluateParams, + token: vscode.CancellationToken ): Promise { log.info('Running a playground...', { connectionId: playgroundExecuteParameters.connectionId, filePath: playgroundExecuteParameters.filePath, inputLength: playgroundExecuteParameters.codeToEvaluate.length, }); - this._isExecutingInProgress = true; - this._consoleOutputChannel.clear(); - // Instantiate a new CancellationTokenSource object - // that generates a cancellation token for each run of a playground. - this._source = new CancellationTokenSource(); - // Send a request with a cancellation token // to the language server instance to execute scripts from a playground // and return results to the playground controller when ready. const res: ShellEvaluateResult = await this._client.sendRequest( ServerCommands.EXECUTE_CODE_FROM_PLAYGROUND, playgroundExecuteParameters, - this._source.token + token ); - this._isExecutingInProgress = false; - log.info('Evaluate response', { namespace: res?.result?.namespace, type: res?.result?.type, @@ -272,18 +259,6 @@ export default class LanguageServerController { ); } - cancelAll(): void { - log.info('Canceling a playground...'); - // Send a request for cancellation. As a result - // the associated CancellationToken will be notified of the cancellation, - // the onCancellationRequested event will be fired, - // and IsCancellationRequested will return true. - if (this._isExecutingInProgress) { - this._source?.cancel(); - this._isExecutingInProgress = false; - } - } - async updateCurrentSessionFields(params): Promise { await this._client.sendRequest( ServerCommands.UPDATE_CURRENT_SESSION_FIELDS, diff --git a/src/language/mongoDBService.ts b/src/language/mongoDBService.ts index 09d4c942d..05ea14a8e 100644 --- a/src/language/mongoDBService.ts +++ b/src/language/mongoDBService.ts @@ -192,7 +192,7 @@ export default class MongoDBService { }; } - async _getAndCacheDatabases() { + async _getAndCacheDatabases(): Promise { try { // Get database names for the current connection. const databases = await this._getDatabases(); @@ -205,7 +205,7 @@ export default class MongoDBService { } } - async _getAndCacheStreamProcessors() { + async _getAndCacheStreamProcessors(): Promise { try { const processors = await this._getStreamProcessors(); this._cacheStreamProcessorCompletionItems(processors); @@ -222,7 +222,7 @@ export default class MongoDBService { async evaluate( params: PlaygroundEvaluateParams, token: CancellationToken - ): Promise { + ): Promise { this.clearCachedFields(); return new Promise((resolve) => { @@ -231,14 +231,14 @@ export default class MongoDBService { ServerCommands.SHOW_ERROR_MESSAGE, "The playground's active connection does not match the extension's active connection. Please reconnect and try again." ); - return resolve(undefined); + return resolve(null); } if (!this._extensionPath) { this._connection.console.error( 'LS evaluate: extensionPath is undefined' ); - return resolve(undefined); + return resolve(null); } try { @@ -274,7 +274,7 @@ export default class MongoDBService { if (name === ServerCommands.CODE_EXECUTION_RESULT) { const { error, data } = payload as { - data?: ShellEvaluateResult; + data: ShellEvaluateResult | null; error?: any; }; if (error) { @@ -313,13 +313,13 @@ export default class MongoDBService { // Stop the worker and all JavaScript execution // in the worker thread as soon as possible. await worker.terminate(); - return resolve(undefined); + return resolve(null); }); } catch (error) { this._connection.console.error( `LS evaluate error: ${util.inspect(error)}` ); - return resolve(undefined); + return resolve(null); } }); } @@ -427,7 +427,7 @@ export default class MongoDBService { /** * Return 'db', 'sp' and 'use' completion items. */ - _cacheGlobalSymbolCompletionItems() { + _cacheGlobalSymbolCompletionItems(): void { this._globalSymbolCompletionItems = [ { label: 'db', @@ -452,7 +452,7 @@ export default class MongoDBService { /** * Create and cache Shell symbols completion items. */ - _cacheShellSymbolCompletionItems() { + _cacheShellSymbolCompletionItems(): void { const shellSymbolCompletionItems = {}; Object.keys(signatures).map((symbol) => { @@ -567,7 +567,10 @@ export default class MongoDBService { }: { operator: string; description?: string; - }) { + }): { + kind: typeof MarkupKind.Markdown; + value: string; + } { const title = operator.replace(/[$]/g, ''); const link = LINKS.aggregationDocs(title); return { @@ -584,7 +587,10 @@ export default class MongoDBService { }: { bsonType: string; description?: string; - }) { + }): { + kind: typeof MarkupKind.Markdown; + value: string; + } { const link = LINKS.bsonDocs(bsonType); return { kind: MarkupKind.Markdown, @@ -600,7 +606,10 @@ export default class MongoDBService { }: { variable: string; description?: string; - }) { + }): { + kind: typeof MarkupKind.Markdown; + value: string; + } { const title = variable.replace(/[$]/g, ''); const link = LINKS.systemVariableDocs(title); return { @@ -617,7 +626,7 @@ export default class MongoDBService { async _getCompletionValuesAndUpdateCache( currentDatabaseName: string | null, currentCollectionName: string | null - ) { + ): Promise { if (currentDatabaseName && !this._collections[currentDatabaseName]) { // Get collection names for the current database. const collections = await this._getCollections(currentDatabaseName); @@ -645,7 +654,9 @@ export default class MongoDBService { /** * If the current node is 'db.collection.aggregate([{}])'. */ - _provideStageCompletionItems(state: CompletionState) { + _provideStageCompletionItems( + state: CompletionState + ): CompletionItem[] | undefined { if (state.isStage) { this._connection.console.log('VISITOR found stage operator completions'); @@ -678,7 +689,9 @@ export default class MongoDBService { * we check a playground text before the current cursor position. * If we found 'use("db")' or 'db.collection' we also suggest field names. */ - _provideQueryOperatorCompletionItems(state: CompletionState) { + _provideQueryOperatorCompletionItems( + state: CompletionState + ): CompletionItem[] | undefined { if ( state.stageOperator === MATCH || (state.stageOperator === null && state.isObjectKey) @@ -718,7 +731,9 @@ export default class MongoDBService { * we check a playground text before the current cursor position. * If we found 'use("db")' or 'db.collection' we also suggest field names. */ - _provideAggregationOperatorCompletionItems(state: CompletionState) { + _provideAggregationOperatorCompletionItems( + state: CompletionState + ): CompletionItem[] | undefined { if (state.stageOperator) { const fields = this._fields[`${state.databaseName}.${state.collectionName}`] || []; @@ -766,7 +781,9 @@ export default class MongoDBService { /** * If the current node is 'db.collection.find({ _id: });'. */ - _provideIdentifierObjectValueCompletionItems(state: CompletionState) { + _provideIdentifierObjectValueCompletionItems( + state: CompletionState + ): CompletionItem[] | undefined { if (state.isIdentifierObjectValue) { this._connection.console.log('VISITOR found bson completions'); return getFilteredCompletions({ meta: ['bson'] }).map((item) => { @@ -795,7 +812,9 @@ export default class MongoDBService { /** * If the current node is 'db.collection.find({ field: "" });'. */ - _provideTextObjectValueCompletionItems(state: CompletionState) { + _provideTextObjectValueCompletionItems( + state: CompletionState + ): CompletionItem[] | undefined { if (state.isTextObjectValue) { const fields = this._fields[`${state.databaseName}.${state.collectionName}`]; @@ -827,7 +846,9 @@ export default class MongoDBService { /** * If the current node is 'db.collection.' or 'db["test"].'. */ - _provideCollectionSymbolCompletionItems(state: CompletionState) { + _provideCollectionSymbolCompletionItems( + state: CompletionState + ): CompletionItem[] | undefined { if (state.isCollectionSymbol) { this._connection.console.log( 'VISITOR found collection symbol completions' @@ -839,7 +860,9 @@ export default class MongoDBService { /** * If the current node is 'sp.processor.' or 'sp["processor"].'. */ - _provideStreamProcessorSymbolCompletionItems(state: CompletionState) { + _provideStreamProcessorSymbolCompletionItems( + state: CompletionState + ): CompletionItem[] | undefined { if (state.isStreamProcessorSymbol) { this._connection.console.log( 'VISITOR found stream processor symbol completions' @@ -851,7 +874,9 @@ export default class MongoDBService { /** * If the current node is 'db.collection.find().'. */ - _provideFindCursorCompletionItems(state: CompletionState) { + _provideFindCursorCompletionItems( + state: CompletionState + ): CompletionItem[] | undefined { if (state.isFindCursor) { this._connection.console.log('VISITOR found find cursor completions'); return this._shellSymbolCompletionItems.Cursor; @@ -861,7 +886,9 @@ export default class MongoDBService { /** * If the current node is 'db.collection.aggregate().'. */ - _provideAggregationCursorCompletionItems(state: CompletionState) { + _provideAggregationCursorCompletionItems( + state: CompletionState + ): CompletionItem[] | undefined { if (state.isAggregationCursor) { this._connection.console.log( 'VISITOR found aggregation cursor completions' @@ -873,7 +900,9 @@ export default class MongoDBService { /** * If the current node is 'db' or 'use'. */ - _provideGlobalSymbolCompletionItems(state: CompletionState) { + _provideGlobalSymbolCompletionItems( + state: CompletionState + ): CompletionItem[] | undefined { if (state.isGlobalSymbol) { this._connection.console.log('VISITOR found global symbol completions'); return this._globalSymbolCompletionItems; @@ -894,7 +923,7 @@ export default class MongoDBService { databaseName: string; currentLineText: string; position: { line: number; character: number }; - }) { + }): CompletionItem[] { return this._collections[databaseName].map((collectionName) => { if (this._isValidPropertyName(collectionName)) { return { @@ -936,7 +965,7 @@ export default class MongoDBService { state: CompletionState, currentLineText: string, position: { line: number; character: number } - ) { + ): CompletionItem[] | undefined { // If we found 'use("db")' and the current node is 'db.'. if (state.isDbSymbol && state.databaseName) { this._connection.console.log( @@ -963,7 +992,9 @@ export default class MongoDBService { /** * If the current node is 'sp.'. */ - _provideSpSymbolCompletionItems(state: CompletionState) { + _provideSpSymbolCompletionItems( + state: CompletionState + ): CompletionItem[] | undefined { if (state.isSpSymbol) { if (state.isStreamProcessorName) { this._connection.console.log( @@ -982,7 +1013,9 @@ export default class MongoDBService { /** * If the current node is 'sp.get()'. */ - _provideStreamProcessorNameCompletionItems(state: CompletionState) { + _provideStreamProcessorNameCompletionItems( + state: CompletionState + ): CompletionItem[] | undefined { if (state.isStreamProcessorName) { this._connection.console.log( 'VISITOR found stream processor name completions' @@ -999,7 +1032,7 @@ export default class MongoDBService { state: CompletionState, currentLineText: string, position: { line: number; character: number } - ) { + ): CompletionItem[] | undefined { if (state.isCollectionName && state.databaseName) { this._connection.console.log('VISITOR found collection name completions'); return this._getCollectionCompletionItems({ @@ -1013,7 +1046,9 @@ export default class MongoDBService { /** * If the current node is 'use(""")'. */ - _provideDbNameCompletionItems(state: CompletionState) { + _provideDbNameCompletionItems( + state: CompletionState + ): CompletionItem[] | undefined { if (state.isUseCallExpression) { this._connection.console.log('VISITOR found database names completion'); return this._databaseCompletionItems; @@ -1094,7 +1129,7 @@ export default class MongoDBService { } // Highlight the usage of commands that only works inside interactive session. - provideDiagnostics(textFromEditor: string) { + provideDiagnostics(textFromEditor: string): Diagnostic[] { const lines = textFromEditor.split(/\r?\n/g); const diagnostics: Diagnostic[] = []; const invalidInteractiveSyntaxes = [ diff --git a/src/language/server.ts b/src/language/server.ts index 0bef401a4..b66793bb5 100644 --- a/src/language/server.ts +++ b/src/language/server.ts @@ -1,4 +1,5 @@ import type { + CancellationToken, InitializeParams, CompletionItem, TextDocumentPositionParams, @@ -161,7 +162,7 @@ connection.onDidChangeWatchedFiles((/* _change */) => { // Execute a playground. connection.onRequest( ServerCommands.EXECUTE_CODE_FROM_PLAYGROUND, - (evaluateParams: PlaygroundEvaluateParams, token) => { + (evaluateParams: PlaygroundEvaluateParams, token: CancellationToken) => { return mongoDBService.evaluate(evaluateParams, token); } ); diff --git a/src/language/worker.ts b/src/language/worker.ts index 3a1d1605d..229b43ee3 100644 --- a/src/language/worker.ts +++ b/src/language/worker.ts @@ -2,6 +2,7 @@ import { CliServiceProvider } from '@mongosh/service-provider-server'; import { ElectronRuntime } from '@mongosh/browser-runtime-electron'; import { parentPort } from 'worker_threads'; import { ServerCommands } from './serverCommands'; +import type { Document } from 'bson'; import type { ShellEvaluateResult, @@ -16,7 +17,7 @@ interface EvaluationResult { type: string | null; } -const getContent = ({ type, printable }: EvaluationResult) => { +const getContent = ({ type, printable }: EvaluationResult): Document => { if (type === 'Cursor' || type === 'AggregationCursor') { return getEJSON(printable.documents); } @@ -26,7 +27,9 @@ const getContent = ({ type, printable }: EvaluationResult) => { : getEJSON(printable); }; -export const getLanguage = (evaluationResult: EvaluationResult) => { +export const getLanguage = ( + evaluationResult: EvaluationResult +): 'json' | 'plaintext' => { const content = getContent(evaluationResult); if (typeof content === 'object' && content !== null) { @@ -44,7 +47,7 @@ type ExecuteCodeOptions = { filePath?: string; }; -function handleEvalPrint(values: EvaluationResult[]) { +function handleEvalPrint(values: EvaluationResult[]): void { parentPort?.postMessage({ name: ServerCommands.SHOW_CONSOLE_OUTPUT, payload: values.map((v) => { @@ -65,7 +68,7 @@ export const execute = async ({ connectionOptions, filePath, }: ExecuteCodeOptions): Promise<{ - data?: ShellEvaluateResult; + data: ShellEvaluateResult | null; error?: any; }> => { const serviceProvider = await CliServiceProvider.connect( @@ -112,7 +115,7 @@ export const execute = async ({ return { data: { result } }; } catch (error) { - return { error }; + return { error, data: null }; } finally { await serviceProvider.close(true); } diff --git a/src/mdbExtensionController.ts b/src/mdbExtensionController.ts index e481a57ea..0f8e6bdf6 100644 --- a/src/mdbExtensionController.ts +++ b/src/mdbExtensionController.ts @@ -233,6 +233,9 @@ export default class MDBExtensionController implements vscode.Disposable { EXTENSION_COMMANDS.MDB_RUN_ALL_OR_SELECTED_PLAYGROUND_BLOCKS, () => this._playgroundController.runAllOrSelectedPlaygroundBlocks() ); + this.registerCommand(EXTENSION_COMMANDS.MDB_EXPORT_CODE_TO_PLAYGROUND, () => + this._participantController.exportCodeToPlayground() + ); this.registerCommand( EXTENSION_COMMANDS.MDB_FIX_THIS_INVALID_INTERACTIVE_SYNTAX, diff --git a/src/participant/participant.ts b/src/participant/participant.ts index 5dbf97b31..ee5655467 100644 --- a/src/participant/participant.ts +++ b/src/participant/participant.ts @@ -40,6 +40,7 @@ import { } from '../telemetry/telemetryService'; import { DocsChatbotAIService } from './docsChatbotAIService'; import type TelemetryService from '../telemetry/telemetryService'; +import formatError from '../utils/formatError'; import type { ModelInput } from './prompts/promptBase'; import { processStreamWithIdentifiers } from './streamParsing'; import type { PromptIntent } from './prompts/intent'; @@ -111,53 +112,6 @@ export default class ParticipantController { return this._participant; } - handleError(err: any, command: string): never { - let errorCode: string | undefined; - let errorName: ParticipantErrorTypes; - // Making the chat request might fail because - // - model does not exist - // - user consent not given - // - quote limits exceeded - if (err instanceof vscode.LanguageModelError) { - errorCode = err.code; - } - - if (err instanceof Error) { - // Unwrap the error if a cause is provided - err = err.cause || err; - } - - const message: string = err.message || err.toString(); - - if (message.includes('off_topic')) { - errorName = ParticipantErrorTypes.CHAT_MODEL_OFF_TOPIC; - } else if (message.includes('Filtered by Responsible AI Service')) { - errorName = ParticipantErrorTypes.FILTERED; - } else if (message.includes('Prompt failed validation')) { - errorName = ParticipantErrorTypes.INVALID_PROMPT; - } else { - errorName = ParticipantErrorTypes.OTHER; - } - - log.error('Participant encountered an error', { - command, - error_code: errorCode, - error_name: errorName, - }); - - this._telemetryService.track( - TelemetryEventTypes.PARTICIPANT_RESPONSE_FAILED, - { - command, - error_code: errorCode, - error_name: errorName, - } - ); - - // Re-throw other errors so they show up in the UI. - throw err; - } - /** * In order to get access to the model, and to write more messages to the chat after * an async event that occurs after we've already completed our response, we need @@ -259,6 +213,30 @@ export default class ParticipantController { }); } + async streamChatResponseContentToPlayground({ + modelInput, + token, + }: { + modelInput: ModelInput; + token: vscode.CancellationToken; + }): Promise { + const chatResponse = await this._getChatResponse({ + modelInput, + token, + }); + + const runnableContent: string[] = []; + await processStreamWithIdentifiers({ + processStreamFragment: () => {}, + onStreamIdentifier: (content: string) => { + runnableContent.push(content.trim()); + }, + inputIterable: chatResponse.text, + identifier: codeBlockIdentifier, + }); + return runnableContent.length ? runnableContent.join('') : null; + } + async streamChatResponseContentWithCodeActions({ modelInput, stream, @@ -1481,6 +1459,94 @@ export default class ParticipantController { }); } + async exportCodeToPlayground(): Promise { + const activeTextEditor = vscode.window.activeTextEditor; + if (!activeTextEditor) { + await vscode.window.showErrorMessage('Active editor not found.'); + return false; + } + + const sortedSelections = Array.from(activeTextEditor.selections).sort( + (a, b) => a.start.compareTo(b.start) + ); + const selectedText = sortedSelections + .map((selection) => activeTextEditor.document.getText(selection)) + .join('\n'); + const code = + selectedText || activeTextEditor.document.getText().trim() || ''; + try { + const progressResult = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Exporting code to a playground...', + cancellable: true, + }, + async (progress, token): Promise => { + const modelInput = await Prompts.exportToPlayground.buildMessages({ + request: { prompt: code }, + }); + + const result = await Promise.race([ + this.getChatResponseContent({ + modelInput, + token, + }), + new Promise((resolve) => + token.onCancellationRequested(() => { + resolve(undefined); + }) + ), + ]); + + if (result?.includes("Sorry, I can't assist with that.")) { + void vscode.window.showErrorMessage( + "Sorry, I can't assist with that." + ); + return null; + } + + return this.streamChatResponseContentToPlayground({ + modelInput, + token, + }); + } + ); + + if (progressResult) { + await vscode.commands.executeCommand( + EXTENSION_COMMANDS.OPEN_PARTICIPANT_CODE_IN_PLAYGROUND, + { + runnableContent: progressResult, + } + ); + } else { + await vscode.window.showErrorMessage('Exporting to playground failed.'); + } + + return true; + } catch (error) { + const message = formatError(error).message; + if ( + error instanceof vscode.LanguageModelError && + message.includes('Canceled') + ) { + await vscode.window.showInformationMessage( + 'The running export to a playground operation was canceled.' + ); + return false; + } + + this._telemetryService.trackCopilotParticipantError( + error, + 'exportToPlayground' + ); + await vscode.window.showErrorMessage( + `An error occurred exporting to a playground: ${message}` + ); + return false; + } + } + async chatHandler( ...args: [ vscode.ChatRequest, @@ -1527,8 +1593,13 @@ Please see our [FAQ](https://www.mongodb.com/docs/generative-ai-faq/) for more i return await this.handleGenericRequest(...args); } - } catch (e) { - this.handleError(e, request.command || 'generic'); + } catch (error) { + this._telemetryService.trackCopilotParticipantError( + error, + request.command || 'generic' + ); + // Re-throw other errors so they show up in the UI. + throw error; } } diff --git a/src/participant/prompts/exportToPlayground.ts b/src/participant/prompts/exportToPlayground.ts new file mode 100644 index 000000000..5b0410c6e --- /dev/null +++ b/src/participant/prompts/exportToPlayground.ts @@ -0,0 +1,61 @@ +import { PromptBase, type PromptArgsBase } from './promptBase'; + +export interface ExportToPlaygroundPromptArgs extends PromptArgsBase { + databaseName: string; + collectionName: string; + schema: string; + amountOfDocumentsSampled: number; + connectionNames: string[]; +} + +export class ExportToPlaygroundPrompt extends PromptBase { + protected getAssistantPrompt(): string { + return `You are a MongoDB expert. +Your task is to convert user's code written in any programming language to the MongoDB mongosh shell script. + +Example: +User: +public class InsertMany { + public static void main(String[] args) { + // Replace the uri string with your MongoDB deployment's connection string + String uri = ""; + try (MongoClient mongoClient = MongoClients.create(uri)) { + MongoDatabase database = mongoClient.getDatabase("sample_mflix"); + MongoCollection collection = database.getCollection("movies"); + List movieList = Arrays.asList( + new Document().append("title", "Short Circuit 3"), + new Document().append("title", "The Lego Frozen Movie")); + try { + InsertManyResult result = collection.insertMany(movieList); + System.out.println("Inserted document ids: " + result.getInsertedIds()); + } catch (MongoException me) { + System.err.println("Unable to insert due to an error: " + me); + } + } + } +} +Response: +class InsertMany { + main(args) { + const uri = "/sample_mflix"; + // Replace the uri string with your MongoDB deployment's connection string + db = connect(uri); + + try { + const ids = db.movies.insertMany([ + { "title": "Short Circuit 3" }, + { "title": "The Lego Frozen Movie" }, + ]); + print('Inserted document ids:'); + printjson(ids.insertedIds); + } catch (error) { + print(error); + } + } +} + +Take a user prompt as an input string and translate it to the MongoDB Shell language. +Keep your response concise. +Respond with markdown, suggest code in a Markdown code block that begins with \`\`\`javascript and ends with \`\`\`.`; + } +} diff --git a/src/participant/prompts/index.ts b/src/participant/prompts/index.ts index 5324f2847..4ed793dc7 100644 --- a/src/participant/prompts/index.ts +++ b/src/participant/prompts/index.ts @@ -5,6 +5,7 @@ import { IntentPrompt } from './intent'; import { NamespacePrompt } from './namespace'; import { QueryPrompt } from './query'; import { SchemaPrompt } from './schema'; +import { ExportToPlaygroundPrompt } from './exportToPlayground'; import { isContentEmpty } from './promptBase'; export { getContentLength } from './promptBase'; @@ -15,6 +16,7 @@ export class Prompts { public static namespace = new NamespacePrompt(); public static query = new QueryPrompt(); public static schema = new SchemaPrompt(); + public static exportToPlayground = new ExportToPlaygroundPrompt(); public static isPromptEmpty(request: vscode.ChatRequest): boolean { return !request.prompt || request.prompt.trim().length === 0; diff --git a/src/participant/prompts/promptBase.ts b/src/participant/prompts/promptBase.ts index 38a8ae672..a390770bb 100644 --- a/src/participant/prompts/promptBase.ts +++ b/src/participant/prompts/promptBase.ts @@ -10,8 +10,8 @@ export interface PromptArgsBase { prompt: string; command?: string; }; - context: vscode.ChatContext; - connectionNames: string[]; + context?: vscode.ChatContext; + connectionNames?: string[]; } export interface UserPromptResponse { @@ -89,8 +89,14 @@ export abstract class PromptBase { // If the current user's prompt is a connection name, and the last // message was to connect. We want to use the last // message they sent before the connection name as their prompt. - if (args.connectionNames.includes(args.request.prompt)) { - const history = args.context.history; + if (args.connectionNames?.includes(args.request.prompt)) { + const history = args.context?.history; + if (!history) { + return { + messages: [], + stats: this.getStats([], args, false), + }; + } const previousResponse = history[ history.length - 1 ] as vscode.ChatResponseTurn; @@ -144,7 +150,7 @@ export abstract class PromptBase { user_input_length: request.prompt.length, has_sample_documents: hasSampleDocs, command: request.command || 'generic', - history_size: context.history.length, + history_size: context?.history.length || 0, internal_purpose: this.internalPurposeForTelemetry, }; } @@ -157,11 +163,15 @@ export abstract class PromptBase { connectionNames, context, }: { - connectionNames: string[]; // Used to scrape the connecting messages from the history. - context: vscode.ChatContext; + connectionNames?: string[]; // Used to scrape the connecting messages from the history. + context?: vscode.ChatContext; }): vscode.LanguageModelChatMessage[] { const messages: vscode.LanguageModelChatMessage[] = []; + if (!context) { + return []; + } + for (const historyItem of context.history) { if (historyItem instanceof vscode.ChatRequestTurn) { if ( diff --git a/src/participant/prompts/query.ts b/src/participant/prompts/query.ts index 1efef4ba6..689400c27 100644 --- a/src/participant/prompts/query.ts +++ b/src/participant/prompts/query.ts @@ -11,7 +11,7 @@ interface QueryPromptArgs extends PromptArgsBase { collectionName: string; schema?: string; sampleDocuments?: Document[]; - connectionNames: string[]; + connectionNames?: string[]; } export class QueryPrompt extends PromptBase { diff --git a/src/participant/prompts/schema.ts b/src/participant/prompts/schema.ts index ca8b54b26..362d1cf8e 100644 --- a/src/participant/prompts/schema.ts +++ b/src/participant/prompts/schema.ts @@ -15,7 +15,9 @@ export interface SchemaPromptArgs extends PromptArgsBase { } export class SchemaPrompt extends PromptBase { - getAssistantPrompt({ amountOfDocumentsSampled }: SchemaPromptArgs): string { + protected getAssistantPrompt({ + amountOfDocumentsSampled, + }: SchemaPromptArgs): string { return `You are a senior engineer who describes the schema of documents in a MongoDB database. The schema is generated from a sample of documents in the user's collection. You must follow these rules. diff --git a/src/telemetry/telemetryService.ts b/src/telemetry/telemetryService.ts index 93220661e..24e4a8773 100644 --- a/src/telemetry/telemetryService.ts +++ b/src/telemetry/telemetryService.ts @@ -48,9 +48,16 @@ type DocumentEditedTelemetryEventProperties = { source: DocumentSource; }; +type AggregationExportedTelemetryEventProperties = { + language: string; + num_stages: number | null; + with_import_statements: boolean; + with_builders: boolean; + with_driver_syntax: boolean; +}; + type QueryExportedTelemetryEventProperties = { language: string; - num_stages?: number; with_import_statements: boolean; with_builders: boolean; with_driver_syntax: boolean; @@ -149,6 +156,7 @@ type TelemetryEventProperties = | ConnectionEditedTelemetryEventProperties | DocumentEditedTelemetryEventProperties | QueryExportedTelemetryEventProperties + | AggregationExportedTelemetryEventProperties | PlaygroundCreatedTelemetryEventProperties | PlaygroundSavedTelemetryEventProperties | PlaygroundLoadedTelemetryEventProperties @@ -413,7 +421,7 @@ export default class TelemetryService { } trackAggregationExported( - aggExportedProps: QueryExportedTelemetryEventProperties + aggExportedProps: AggregationExportedTelemetryEventProperties ): void { this.track(TelemetryEventTypes.AGGREGATION_EXPORTED, aggExportedProps); } @@ -446,6 +454,41 @@ export default class TelemetryService { this.track(TelemetryEventTypes.PARTICIPANT_FEEDBACK, props); } + trackCopilotParticipantError(err: any, command: string): void { + let errorCode: string | undefined; + let errorName: ParticipantErrorTypes; + // Making the chat request might fail because + // - model does not exist + // - user consent not given + // - quote limits exceeded + if (err instanceof vscode.LanguageModelError) { + errorCode = err.code; + } + + if (err instanceof Error) { + // Unwrap the error if a cause is provided + err = err.cause || err; + } + + const message: string = err.message || err.toString(); + + if (message.includes('off_topic')) { + errorName = ParticipantErrorTypes.CHAT_MODEL_OFF_TOPIC; + } else if (message.includes('Filtered by Responsible AI Service')) { + errorName = ParticipantErrorTypes.FILTERED; + } else if (message.includes('Prompt failed validation')) { + errorName = ParticipantErrorTypes.INVALID_PROMPT; + } else { + errorName = ParticipantErrorTypes.OTHER; + } + + this.track(TelemetryEventTypes.PARTICIPANT_RESPONSE_FAILED, { + command, + error_code: errorCode, + error_name: errorName, + }); + } + trackCopilotParticipantPrompt(stats: ParticipantPromptProperties): void { this.track(TelemetryEventTypes.PARTICIPANT_PROMPT_SUBMITTED, stats); } diff --git a/src/test/suite/editors/playgroundController.test.ts b/src/test/suite/editors/playgroundController.test.ts index 125ba4a7e..14889416f 100644 --- a/src/test/suite/editors/playgroundController.test.ts +++ b/src/test/suite/editors/playgroundController.test.ts @@ -213,8 +213,8 @@ suite('Playground Controller Test Suite', function () { document: { languageId: 'javascript', uri: mockDocumentUri, - getText: () => "use('dbName');", - lineAt: () => ({ text: "use('dbName');" }), + getText: (): string => "use('dbName');", + lineAt: (): { text: string } => ({ text: "use('dbName');" }), }, selections: [ new vscode.Selection( @@ -317,20 +317,6 @@ suite('Playground Controller Test Suite', function () { expect(showTextDocumentOptions.viewColumn).to.be.equal(-2); }); - test('close cancelation modal when a playground is canceled', async () => { - sandbox.replace( - testPlaygroundController, - '_evaluate', - sandbox.fake.rejects(false) - ); - - const result = await testPlaygroundController._evaluateWithCancelModal({ - codeToEvaluate: '', - }); - - expect(result).to.deep.equal({ result: undefined }); - }); - test('playground controller loads the active editor on start', () => { sandbox.replaceGetter( vscode.window, diff --git a/src/test/suite/language/languageServerController.test.ts b/src/test/suite/language/languageServerController.test.ts index 87d09b17b..45919d9b0 100644 --- a/src/test/suite/language/languageServerController.test.ts +++ b/src/test/suite/language/languageServerController.test.ts @@ -105,34 +105,6 @@ suite('Language Server Controller Test Suite', () => { sandbox.restore(); }); - test('cancel a long-running script', async () => { - expect(languageServerControllerStub._isExecutingInProgress).to.equal(false); - - await languageServerControllerStub.evaluate({ - codeToEvaluate: ` - const names = [ - "flour", - "butter", - "water", - "salt", - "onions", - "leek" - ]; - let currentName = ''; - names.forEach((name) => { - setTimeout(() => { - currentName = name; - }, 500); - }); - currentName - `, - connectionId: 'pineapple', - }); - - languageServerControllerStub.cancelAll(); - expect(languageServerControllerStub._isExecutingInProgress).to.equal(false); - }); - test('the language server dependency bundle exists', async () => { const extensionPath = mdbTestExtension.extensionContextStub.extensionPath; const languageServerModuleBundlePath = path.join( @@ -176,13 +148,17 @@ suite('Language Server Controller Test Suite', () => { expect(outputChannelClearStub).to.not.be.called; - await languageServerControllerStub.evaluate({ - codeToEvaluate: ` + const source = new vscode.CancellationTokenSource(); + await languageServerControllerStub.evaluate( + { + codeToEvaluate: ` print('test'); console.log({ pineapple: 'yes' }); `, - connectionId: 'pineapple', - }); + connectionId: 'pineapple', + }, + source.token + ); expect(outputChannelClearStub).to.be.calledOnce; }); diff --git a/src/test/suite/language/mongoDBService.test.ts b/src/test/suite/language/mongoDBService.test.ts index 6f7e1fe5d..c3fc37f60 100644 --- a/src/test/suite/language/mongoDBService.test.ts +++ b/src/test/suite/language/mongoDBService.test.ts @@ -73,7 +73,7 @@ suite('MongoDBService Test Suite', () => { source.token ); - expect(result).to.be.equal(undefined); + expect(result).to.be.equal(null); }); test('catches error when _getCollectionsCompletionItems is called and extension path is empty string', async () => { @@ -2729,7 +2729,7 @@ suite('MongoDBService Test Suite', () => { source.token ); - expect(result).to.equal(undefined); + expect(result).to.equal(null); }); test('evaluate multiplies commands at once', async () => { diff --git a/src/test/suite/participant/participant.test.ts b/src/test/suite/participant/participant.test.ts index 094ff3681..61eeb5c6f 100644 --- a/src/test/suite/participant/participant.test.ts +++ b/src/test/suite/participant/participant.test.ts @@ -28,6 +28,8 @@ import { } from '../../../storage/storageController'; import type { LoadedConnection } from '../../../storage/connectionStorage'; import { ChatMetadataStore } from '../../../participant/chatMetadata'; +import { getFullRange } from '../suggestTestHelpers'; +import { isPlayground } from '../../../utils/playground'; import { Prompts } from '../../../participant/prompts'; import { createMarkdownLink } from '../../../participant/markdown'; import EXTENSION_COMMANDS from '../../../commands'; @@ -210,14 +212,7 @@ suite('Participant Controller Test Suite', function () { }; countTokensStub = sinon.stub(); // The model returned by vscode.lm.selectChatModels is always undefined in tests. - sendRequestStub = sinon.stub().resolves({ - text: [ - '```javascript\n' + - "use('dbOne');\n" + - "db.getCollection('collOne').find({ name: 'example' });\n" + - '```', - ], - }); + sendRequestStub = sinon.stub(); sinon.replace( vscode.lm, 'selectChatModels', @@ -462,6 +457,14 @@ suite('Participant Controller Test Suite', function () { 'get', sinon.fake.returns(false) ); + sendRequestStub.resolves({ + text: [ + '```javascript\n' + + "use('dbOne');\n" + + "db.getCollection('collOne').find({ name: 'example' });\n" + + '```', + ], + }); }); test('prints a welcome message to chat', async function () { @@ -520,6 +523,17 @@ suite('Participant Controller Test Suite', function () { }); suite('generic command', function () { + beforeEach(function () { + sendRequestStub.resolves({ + text: [ + '```javascript\n' + + "use('dbOne');\n" + + "db.getCollection('collOne').find({ name: 'example' });\n" + + '```', + ], + }); + }); + suite('when the intent is recognized', function () { beforeEach(function () { sendRequestStub.onCall(0).resolves({ @@ -617,6 +631,17 @@ suite('Participant Controller Test Suite', function () { }); suite('query command', function () { + beforeEach(function () { + sendRequestStub.resolves({ + text: [ + '```javascript\n' + + "use('dbOne');\n" + + "db.getCollection('collOne').find({ name: 'example' });\n" + + '```', + ], + }); + }); + suite('known namespace from running namespace LLM', function () { beforeEach(function () { sendRequestStub.onCall(0).resolves({ @@ -1380,6 +1405,17 @@ suite('Participant Controller Test Suite', function () { }); suite('schema command', function () { + beforeEach(function () { + sendRequestStub.resolves({ + text: [ + '```javascript\n' + + "use('dbOne');\n" + + "db.getCollection('collOne').find({ name: 'example' });\n" + + '```', + ], + }); + }); + suite('no namespace provided', function () { beforeEach(function () { sendRequestStub.onCall(0).resolves({ @@ -1721,6 +1757,108 @@ Schema: }); }); }); + + suite('export to playground', function () { + beforeEach(async function () { + await vscode.commands.executeCommand( + 'workbench.action.files.newUntitledFile' + ); + }); + + afterEach(async function () { + await vscode.commands.executeCommand( + 'workbench.action.closeActiveEditor' + ); + }); + + test('exports all code to a playground', async function () { + const editor = vscode.window.activeTextEditor; + if (!editor) { + throw new Error('Window active text editor is undefined'); + } + + const testDocumentUri = editor.document.uri; + const edit = new vscode.WorkspaceEdit(); + const code = ` + InsertOneResult result = collection.insertOne(new Document() + .append("_id", new ObjectId()) + .append("title", "Ski Bloopers") + .append("genres", Arrays.asList("Documentary", "Comedy"))); + System.out.println("Success! Documents were inserted"); +`; + edit.replace(testDocumentUri, getFullRange(editor.document), code); + await vscode.workspace.applyEdit(edit); + sendRequestStub.resolves({ + text: [ + '```javascript\n' + + 'db.collection.insertOne({\n' + + '_id: new ObjectId(),\n' + + 'title: "Ski Bloopers",\n' + + 'genres: ["Documentary", "Comedy"]\n' + + '});\n' + + 'print("Success! Documents were inserted");\n' + + '```', + ], + }); + await testParticipantController.exportCodeToPlayground(); + const messages = sendRequestStub.firstCall.args[0]; + expect(getMessageContent(messages[1])).to.include( + 'System.out.println' + ); + expect( + isPlayground(vscode.window.activeTextEditor?.document.uri) + ).to.be.eql(true); + expect(vscode.window.activeTextEditor?.document.getText()).to.include( + 'Success! Documents were inserted' + ); + }); + + test('exports selected lines of code to a playground', async function () { + const editor = vscode.window.activeTextEditor; + if (!editor) { + throw new Error('Window active text editor is undefined'); + } + + const testDocumentUri = editor.document.uri; + const edit = new vscode.WorkspaceEdit(); + const code = ` + InsertOneResult result = collection.insertOne(new Document() + .append("_id", new ObjectId()) + .append("title", "Ski Bloopers") + .append("genres", Arrays.asList("Documentary", "Comedy"))); + System.out.println("Success! Documents were inserted"); +`; + edit.replace(testDocumentUri, getFullRange(editor.document), code); + await vscode.workspace.applyEdit(edit); + const position = editor.selection.active; + const startPosition = position.with(0, 0); + const endPosition = position.with(3, 63); + const newSelection = new vscode.Selection(startPosition, endPosition); + editor.selection = newSelection; + sendRequestStub.resolves({ + text: [ + 'Text before code.\n' + + '```javascript\n' + + 'db.collection.insertOne({\n' + + '_id: new ObjectId(),\n' + + 'title: "Ski Bloopers",\n' + + 'genres: ["Documentary", "Comedy"]\n' + + '});\n' + + '```\n' + + 'Text after code.', + ], + }); + await testParticipantController.exportCodeToPlayground(); + const messages = sendRequestStub.firstCall.args[0]; + expect(messages[1].content).to.not.include('System.out.println'); + expect( + isPlayground(vscode.window.activeTextEditor?.document.uri) + ).to.be.eql(true); + expect( + vscode.window.activeTextEditor?.document.getText() + ).to.not.include('"Success! Documents were inserted"'); + }); + }); }); }); @@ -2047,8 +2185,9 @@ Schema: test('reports error', function () { const err = Error('Filtered by Responsible AI Service'); - expect(() => testParticipantController.handleError(err, 'query')).throws( - 'Filtered by Responsible AI Service' + testParticipantController._telemetryService.trackCopilotParticipantError( + err, + 'query' ); sinon.assert.calledOnce(telemetryTrackStub); @@ -2067,8 +2206,9 @@ Schema: test('reports nested error', function () { const err = new Error('Parent error'); err.cause = Error('This message is flagged as off topic: off_topic.'); - expect(() => testParticipantController.handleError(err, 'docs')).throws( - 'off_topic' + testParticipantController._telemetryService.trackCopilotParticipantError( + err, + 'docs' ); sinon.assert.calledOnce(telemetryTrackStub); @@ -2085,8 +2225,9 @@ Schema: test('Reports error code when available', function () { // eslint-disable-next-line new-cap const err = vscode.LanguageModelError.NotFound('Model not found'); - expect(() => testParticipantController.handleError(err, 'schema')).throws( - 'Model not found' + testParticipantController._telemetryService.trackCopilotParticipantError( + err, + 'schema' ); sinon.assert.calledOnce(telemetryTrackStub); diff --git a/src/test/suite/playground.test.ts b/src/test/suite/playground.test.ts index db142ee88..8733d28b6 100644 --- a/src/test/suite/playground.test.ts +++ b/src/test/suite/playground.test.ts @@ -87,7 +87,7 @@ suite('Playground', function () { .update('confirmRunAll', false); }); - afterEach(async () => { + afterEach(async function () { await vscode.commands.executeCommand('workbench.action.closeActiveEditor'); disposeAll(_disposables); sandbox.restore(); diff --git a/src/test/suite/stubs.ts b/src/test/suite/stubs.ts index bb1f09df2..11ac366d7 100644 --- a/src/test/suite/stubs.ts +++ b/src/test/suite/stubs.ts @@ -265,7 +265,6 @@ class LanguageServerControllerStub { _context: ExtensionContextStub; _storageController?: StorageController; _source?: CancellationTokenSource; - _isExecutingInProgress: boolean; _client: LanguageClient; _currentConnectionId: string | null = null; _consoleOutputChannel = @@ -331,7 +330,6 @@ class LanguageServerControllerStub { serverOptions, clientOptions ); - this._isExecutingInProgress = false; } startLanguageServer(): Promise { diff --git a/src/test/suite/suggestTestHelpers.ts b/src/test/suite/suggestTestHelpers.ts index 998715525..4a2336358 100644 --- a/src/test/suite/suggestTestHelpers.ts +++ b/src/test/suite/suggestTestHelpers.ts @@ -65,7 +65,7 @@ export function acceptFirstSuggestion( _disposables, async () => { await vscode.commands.executeCommand('editor.action.triggerSuggest'); - await wait(1000); + await wait(3000); await vscode.commands.executeCommand('acceptSelectedSuggestion'); } ); diff --git a/src/test/suite/telemetry/telemetryService.test.ts b/src/test/suite/telemetry/telemetryService.test.ts index f6e78e374..3e63a59eb 100644 --- a/src/test/suite/telemetry/telemetryService.test.ts +++ b/src/test/suite/telemetry/telemetryService.test.ts @@ -235,9 +235,13 @@ suite('Telemetry Controller Test Suite', () => { test('track playground code executed event', async () => { const testPlaygroundController = mdbTestExtension.testExtensionController._playgroundController; - await testPlaygroundController._evaluate({ - codeToEvaluate: 'show dbs', - }); + const source = new vscode.CancellationTokenSource(); + await testPlaygroundController._evaluate( + { + codeToEvaluate: 'show dbs', + }, + source.token + ); sandbox.assert.calledWith( fakeSegmentAnalyticsTrack, sinon.match({ @@ -641,7 +645,7 @@ suite('Telemetry Controller Test Suite', () => { namespace: 'waffle.house', documentIndexInTree: 0, dataService: dataServiceStub, - resetDocumentListCache: () => Promise.resolve(), + resetDocumentListCache: (): Promise => Promise.resolve(), }); await vscode.commands.executeCommand( 'mdb.cloneDocumentFromTreeView', diff --git a/src/types/playgroundType.ts b/src/types/playgroundType.ts index 0e43a9f21..859b8a487 100644 --- a/src/types/playgroundType.ts +++ b/src/types/playgroundType.ts @@ -12,11 +12,9 @@ export type PlaygroundDebug = OutputItem[] | undefined; export type PlaygroundResult = OutputItem | undefined; -export type ShellEvaluateResult = - | { - result: PlaygroundResult; - } - | undefined; +export type ShellEvaluateResult = { + result: PlaygroundResult; +} | null; export type PlaygroundEvaluateParams = { codeToEvaluate: string;