diff --git a/.eslint-ignore b/.eslint-ignore index 57d8bc4ba67ff..c16b228723218 100644 --- a/.eslint-ignore +++ b/.eslint-ignore @@ -1,9 +1,25 @@ **/build/*/**/*.js -**/dist/**/*.js +**/dist/**/* **/extensions/**/*.d.ts **/extensions/**/build/** **/extensions/**/colorize-fixtures/** -**/extensions/copilot/** +**/extensions/copilot/coverage/** +**/extensions/copilot/.esbuild/** +**/extensions/copilot/.simulation/** +**/extensions/copilot/.eslintplugin/** +**/extensions/copilot/chat-lib/** +**/extensions/copilot/test/simulation/fixtures/** +**/extensions/copilot/test/scenarios/** +**/extensions/copilot/test/aml/out/** +**/extensions/copilot/src/util/vs/** +**/extensions/copilot/src/platform/parser/test/node/fixtures/** +**/extensions/copilot/src/extension/test/node/fixtures/** +**/extensions/copilot/src/extension/prompts/node/test/fixtures/** +**/extensions/copilot/src/extension/typescriptContext/serverPlugin/fixtures/** +**/extensions/copilot/src/extension/typescriptContext/serverPlugin/lib/** +**/extensions/copilot/src/extension/typescriptContext/serverPlugin/dist/** +**/extensions/copilot/src/extension/completions-core/**/testdata/* +**/extensions/copilot/.vscode/extensions/test-extension/dist/** **/extensions/css-language-features/server/test/pathCompletionFixtures/** **/extensions/html-language-features/server/lib/jquery.d.ts **/extensions/html-language-features/server/src/test/pathCompletionFixtures/** @@ -36,4 +52,5 @@ **/test/automation/out/** **/typings/** **/.build/** +**/.vscode-test/** !.vscode diff --git a/.github/workflows/pr-linux-cli-test.yml b/.github/workflows/pr-linux-cli-test.yml index 78d4c4acdc3a4..e5c5dcd973e69 100644 --- a/.github/workflows/pr-linux-cli-test.yml +++ b/.github/workflows/pr-linux-cli-test.yml @@ -11,7 +11,7 @@ on: jobs: linux-cli-test: name: ${{ inputs.job_name }} - runs-on: ubuntu-22.04 + runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64, "JobId=linux-cli-test-${{ inputs.job_name }}-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] env: RUSTUP_TOOLCHAIN: ${{ inputs.rustup_toolchain }} steps: diff --git a/.github/workflows/pr-node-modules.yml b/.github/workflows/pr-node-modules.yml index 952938c0df4cf..731259eb9623d 100644 --- a/.github/workflows/pr-node-modules.yml +++ b/.github/workflows/pr-node-modules.yml @@ -10,7 +10,7 @@ permissions: {} jobs: compile: name: Compile - runs-on: ubuntu-22.04 + runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64, "JobId=compile-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] steps: - name: Checkout microsoft/vscode uses: actions/checkout@v6 @@ -86,7 +86,7 @@ jobs: linux: name: Linux - runs-on: ubuntu-22.04 + runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64, "JobId=linux-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] env: NPM_ARCH: x64 VSCODE_ARCH: x64 @@ -219,7 +219,7 @@ jobs: windows: name: Windows - runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-windows-2022-x64 ] + runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-windows-2022-x64, "JobId=windows-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] env: NPM_ARCH: x64 VSCODE_ARCH: x64 diff --git a/.github/workflows/pr-win32-test.yml b/.github/workflows/pr-win32-test.yml index 7a46a9a48bdad..eb3668d88ae63 100644 --- a/.github/workflows/pr-win32-test.yml +++ b/.github/workflows/pr-win32-test.yml @@ -17,7 +17,7 @@ on: jobs: windows-test: name: ${{ inputs.job_name }} - runs-on: windows-2022 + runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-windows-2022-x64, "JobId=windows-test-${{ inputs.job_name }}-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] env: ARTIFACT_NAME: ${{ (inputs.electron_tests && 'electron') || (inputs.browser_tests && 'browser') || (inputs.remote_tests && 'remote') || 'unknown' }} NPM_ARCH: x64 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 70c65ddc42662..fd4e365401b19 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -19,7 +19,7 @@ env: jobs: compile: name: Compile & Hygiene - runs-on: ubuntu-22.04 + runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64, "JobId=compile-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] steps: - name: Checkout microsoft/vscode uses: actions/checkout@v6 @@ -65,7 +65,7 @@ jobs: env: ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ github.event.pull_request.head.repo.full_name == github.repository && secrets.VSCODE_OSS || secrets.GITHUB_TOKEN }} - name: Create node_modules archive if: steps.cache-node-modules.outputs.cache-hit != 'true' @@ -82,7 +82,7 @@ jobs: - name: Compile & Hygiene run: npm exec -- npm-run-all2 -lp core-ci hygiene eslint valid-layers-check define-class-fields-check vscode-dts-compile-check tsec-compile-check test-build-scripts env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ github.event.pull_request.head.repo.full_name == github.repository && secrets.VSCODE_OSS || secrets.GITHUB_TOKEN }} - name: Check cyclic dependencies run: node build/lib/checkCyclicDependencies.ts out-build @@ -159,7 +159,7 @@ jobs: copilot-check-test-cache: name: Copilot - Check Test Cache - runs-on: ubuntu-22.04 + runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64, "JobId=copilot-check-test-cache-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] permissions: contents: read pull-requests: read @@ -205,7 +205,7 @@ jobs: copilot-check-telemetry: name: Copilot - Check Telemetry - runs-on: ubuntu-22.04 + runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64, "JobId=copilot-check-telemetry-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] permissions: contents: read steps: @@ -224,7 +224,7 @@ jobs: copilot-linux-tests: name: Copilot - Test (Linux) - runs-on: ubuntu-22.04 + runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64, "JobId=copilot-linux-tests-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] permissions: contents: read steps: @@ -288,10 +288,6 @@ jobs: working-directory: extensions/copilot run: npm run typecheck - - name: Lint - working-directory: extensions/copilot - run: npm run lint - - name: Compile working-directory: extensions/copilot run: npm run compile @@ -329,7 +325,7 @@ jobs: copilot-windows-tests: name: Copilot - Test (Windows) - runs-on: windows-2022 + runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-windows-2022-x64, "JobId=copilot-windows-tests-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] permissions: contents: read steps: @@ -387,10 +383,6 @@ jobs: working-directory: extensions/copilot run: npm run typecheck - - name: Lint - working-directory: extensions/copilot - run: npm run lint - - name: Compile working-directory: extensions/copilot run: npm run compile diff --git a/build/azure-pipelines/copilot/setup-steps.yml b/build/azure-pipelines/copilot/setup-steps.yml index 423d1afee09ee..2452681b6d1fe 100644 --- a/build/azure-pipelines/copilot/setup-steps.yml +++ b/build/azure-pipelines/copilot/setup-steps.yml @@ -52,6 +52,11 @@ steps: displayName: Install build dependencies condition: and(succeeded(), ne(variables.BUILD_CACHE_RESTORED, 'true')) + - script: npm ci --ignore-scripts --no-workspaces + workingDirectory: $(Build.SourcesDirectory) + displayName: Install vscode dependencies + condition: and(succeeded(), ne(variables.BUILD_CACHE_RESTORED, 'true')) + - script: npm ci workingDirectory: $(Build.SourcesDirectory)/extensions/copilot displayName: Install copilot dependencies diff --git a/build/azure-pipelines/linux/steps/product-build-linux-compile.yml b/build/azure-pipelines/linux/steps/product-build-linux-compile.yml index 3edc9d66bf8a2..1b94a984e20c3 100644 --- a/build/azure-pipelines/linux/steps/product-build-linux-compile.yml +++ b/build/azure-pipelines/linux/steps/product-build-linux-compile.yml @@ -412,24 +412,6 @@ steps: VSCODE_RUN_BROWSER_TESTS: ${{ parameters.VSCODE_RUN_BROWSER_TESTS }} VSCODE_RUN_REMOTE_TESTS: ${{ parameters.VSCODE_RUN_REMOTE_TESTS }} - - script: | - set -e - echo "Disk usage before cleanup:" - df -h / - # Remove build intermediates no longer needed after tests - rm -rf .build/sysroots - rm -rf .build/electron - rm -rf out - rm -rf out-build - rm -rf out-vscode-min - rm -rf out-vscode-reh-min - rm -rf out-vscode-reh-web-min - rm -rf ~/.cache/ms-playwright - echo "Disk usage after cleanup:" - df -h / - displayName: Free disk space - condition: succeededOrFailed() - - ${{ if ne(parameters.VSCODE_CIBUILD, true) }}: - script: npx deemon --attach node build/azure-pipelines/linux/codesign.ts condition: succeededOrFailed() diff --git a/build/filters.ts b/build/filters.ts index f43780b6b182f..0c7c77d30092f 100644 --- a/build/filters.ts +++ b/build/filters.ts @@ -233,10 +233,8 @@ export const tsFormattingFilter = Object.freeze([ ]); export const eslintFilter = Object.freeze([ - '**/*.js', - '**/*.cjs', - '**/*.mjs', - '**/*.ts', + '**/*.{js,cjs,mjs}', + '**/*.{ts,tsx,mts,cts}', '.eslint-plugin-local/**/*.ts', ...readFileSync(join(import.meta.dirname, '..', '.eslint-ignore')) .toString() diff --git a/build/hygiene.ts b/build/hygiene.ts index a998b0633540a..2418172b65636 100644 --- a/build/hygiene.ts +++ b/build/hygiene.ts @@ -310,20 +310,6 @@ if (import.meta.main) { } } - // Run copilot pre-commit checks if copilot files are staged - if (some.some(f => f.startsWith('extensions/copilot/'))) { - console.log('Running copilot pre-commit checks...'); - const result = cp.spawnSync('npx', ['lint-staged'], { - cwd: path.join(process.cwd(), 'extensions', 'copilot'), - stdio: 'inherit', - shell: true, - }); - if (result.status !== 0) { - console.error('Copilot pre-commit checks failed.'); - process.exit(1); - } - } - console.log('Reading git index versions...'); createGitIndexVinyls(some) diff --git a/eslint.config.js b/eslint.config.js index 1960db435509e..e890bcae9ad69 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -4,11 +4,14 @@ *--------------------------------------------------------------------------------------------*/ // @ts-check import fs from 'fs'; +import { builtinModules } from 'module'; import path from 'path'; import tseslint from 'typescript-eslint'; import stylisticTs from '@stylistic/eslint-plugin-ts'; import * as pluginLocal from './.eslint-plugin-local/index.ts'; +import * as pluginCopilotLocal from './extensions/copilot/.eslintplugin/index.ts'; +import pluginImport from 'eslint-plugin-import'; import pluginJsdoc from 'eslint-plugin-jsdoc'; import pluginHeader from 'eslint-plugin-header'; @@ -193,6 +196,7 @@ export default tseslint.config( 'src/bootstrap-node.ts', 'build/lib/extensions.ts', 'build/lib/test/render.test.ts', + 'extensions/copilot/**/*', 'extensions/debug-auto-launch/src/extension.ts', 'extensions/emmet/src/updateImageSize.ts', 'extensions/emmet/src/util.ts', @@ -2298,16 +2302,16 @@ export default tseslint.config( 'comma-dangle': ['warn', 'only-multiline'] } }, - // Extension main sources (excluding tests) + // Ban dynamic require() and import() calls in extensions to ensure tree-shaking works { files: [ - 'extensions/**/*.ts', + 'extensions/**/*.{ts,tsx}', ], ignores: [ 'extensions/**/*.test.ts', + 'extensions/copilot/**/*', ], rules: { - // Ban dynamic require() and import() calls in extensions to ensure tree-shaking works 'no-restricted-syntax': [ 'warn', { @@ -2389,6 +2393,406 @@ export default tseslint.config( '@typescript-eslint/consistent-generic-constructors': ['warn', 'constructor'], } }, + // copilot extension - main sources + { + files: [ + 'extensions/copilot/src/**/*.{ts,tsx}', + 'extensions/copilot/test/**/*.{ts,tsx}', + ], + ignores: [ + 'extensions/copilot/**/.esbuild.ts', + 'extensions/copilot/src/extension/completions-core/vscode-node/bridge/src/completionsTelemetryServiceBridge.ts', + ], + languageOptions: { + parser: tseslint.parser, + }, + plugins: { + 'import': pluginImport, + 'copilot-local': pluginCopilotLocal, + }, + rules: { + 'local/code-no-dangerous-type-assertions': 'off', + 'local/code-no-any-casts': 'off', + 'local/code-no-deep-import-of-internal': 'off', + 'no-restricted-imports': [ + 'warn', + // node: builtins + ...builtinModules, + // node: dependencies + '@humanwhocodes/gitignore-to-minimatch', + '@vscode/extension-telemetry', + 'applicationinsights', + 'ignore', + 'isbinaryfile', + 'minimatch', + 'source-map-support', + 'vscode-tas-client', + 'web-tree-sitter' + ], + 'import/no-restricted-paths': [ + 'warn', + { + zones: [ + { + target: '**/common/**', + from: [ + '**/vscode/**', + '**/node/**', + '**/vscode-node/**', + '**/worker/**', + '**/vscode-worker/**' + ] + }, + { + target: '**/vscode/**', + from: [ + '**/node/**', + '**/vscode-node/**', + '**/worker/**', + '**/vscode-worker/**' + ] + }, + { + target: '**/node/**', + from: [ + '**/vscode/**', + '**/vscode-node/**', + '**/worker/**', + '**/vscode-worker/**' + ] + }, + { + target: '**/vscode-node/**', + from: [ + '**/worker/**', + '**/vscode-worker/**' + ] + }, + { + target: '**/worker/**', + from: [ + '**/vscode/**', + '**/node/**', + '**/vscode-node/**', + '**/vscode-worker/**' + ] + }, + { + target: '**/vscode-worker/**', + from: [ + '**/node/**', + '**/vscode-node/**' + ] + }, + { + target: './extensions/copilot/src/', + from: './extensions/copilot/test/' + }, + { + target: './extensions/copilot/src/shared-fetch-utils', + from: ['./extensions/copilot/src/extension', './extensions/copilot/src/platform', './extensions/copilot/src/util', './extensions/copilot/src/lib'] + }, + { + target: './extensions/copilot/src/util', + from: ['./extensions/copilot/src/platform', './extensions/copilot/src/extension'] + }, + { + target: './extensions/copilot/src/platform', + from: ['./extensions/copilot/src/extension'] + }, + { + target: ['./extensions/copilot/test', '!./extensions/copilot/test/base/extHostContext/*.ts'], + from: ['**/vscode-node/**', '**/vscode-worker/**'] + }, + { + target: 'extensions/copilot/src/!(lib)/**', + from: './extensions/copilot/src/lib' + } + ] + } + ], + 'copilot-local/no-instanceof-uri': ['warn'], + 'copilot-local/no-test-imports': ['warn'], + 'copilot-local/no-runtime-import': [ + 'warn', + { + test: ['vscode'], + 'src/**/common/**/*': ['vscode'], + 'src/**/node/**/*': ['vscode'] + } + ], + 'copilot-local/no-funny-filename': ['warn'], + 'copilot-local/no-bad-gdpr-comment': ['warn'], + 'copilot-local/no-gdpr-event-name-mismatch': ['warn'], + 'copilot-local/no-unlayered-files': ['warn'], + 'copilot-local/no-restricted-copilot-pr-string': [ + 'warn', + { + className: 'GitHubPullRequestProviders', + string: 'Generate with Copilot' + } + ], + 'copilot-local/no-nls-localize': ['warn'], + } + }, + // copilot extension - allow node imports in node layer + { + files: [ + 'extensions/copilot/**/{vscode-node,node}/**/*.ts', + 'extensions/copilot/**/{vscode-node,node}/**/*.tsx', + ], + rules: { + 'no-restricted-imports': 'off' + } + }, + // copilot extension - override files (tests, build, etc.) + { + files: [ + 'extensions/copilot/test/**', + 'extensions/copilot/src/vscodeTypes.ts', + 'extensions/copilot/script/**', + 'extensions/copilot/src/extension/*.d.ts', + 'extensions/copilot/build/**', + ], + rules: { + 'copilot-local/no-unlayered-files': 'off', + 'no-restricted-imports': 'off' + } + }, + // copilot extension - TSX linebreak rule + { + files: [ + 'extensions/copilot/src/extension/**/*.tsx', + ], + plugins: { + 'copilot-local': pluginCopilotLocal, + }, + rules: { + 'copilot-local/no-missing-linebreak': 'warn' + } + }, + // copilot extension - test-only rule + { + files: [ + 'extensions/copilot/**/*.test.ts', + 'extensions/copilot/**/*.test.tsx', + ], + plugins: { + 'copilot-local': pluginCopilotLocal, + }, + rules: { + 'copilot-local/no-test-only': 'warn' + } + }, + // copilot extension - no-explicit-any + { + files: [ + 'extensions/copilot/src/**/*.ts', + ], + ignores: [ + 'extensions/copilot/src/util/vs/**/*.ts', + 'extensions/copilot/src/**/*.spec.ts', + 'extensions/copilot/src/extension/agents/copilotcli/node/nodePtyShim.ts', + 'extensions/copilot/src/extension/byok/common/anthropicMessageConverter.ts', + 'extensions/copilot/src/extension/byok/common/geminiFunctionDeclarationConverter.ts', + 'extensions/copilot/src/extension/byok/common/geminiMessageConverter.ts', + 'extensions/copilot/src/extension/byok/vscode-node/anthropicProvider.ts', + 'extensions/copilot/src/extension/byok/vscode-node/geminiNativeProvider.ts', + 'extensions/copilot/src/extension/byok/vscode-node/ollamaProvider.ts', + 'extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionContentBuilder.ts', + 'extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts', + 'extensions/copilot/src/extension/codeBlocks/node/codeBlockProcessor.ts', + 'extensions/copilot/src/extension/codeBlocks/vscode-node/provider.ts', + 'extensions/copilot/src/extension/configuration/vscode-node/configurationMigration.ts', + 'extensions/copilot/src/extension/context/node/resolvers/genericInlineIntentInvocation.ts', + 'extensions/copilot/src/extension/context/node/resolvers/genericPanelIntentInvocation.ts', + 'extensions/copilot/src/extension/context/node/resolvers/inlineFixIntentInvocation.ts', + 'extensions/copilot/src/extension/context/node/resolvers/promptWorkspaceLabels.ts', + 'extensions/copilot/src/extension/contextKeys/vscode-node/contextKeys.contribution.ts', + 'extensions/copilot/src/extension/conversation/vscode-node/userActions.ts', + 'extensions/copilot/src/extension/extension/vscode/services.ts', + 'extensions/copilot/src/extension/inlineChat/node/rendererVisualization.ts', + 'extensions/copilot/src/extension/inlineChat/vscode-node/inlineChatCommands.ts', + 'extensions/copilot/src/extension/inlineEdits/common/observableWorkspaceRecordingReplayer.ts', + 'extensions/copilot/src/extension/inlineEdits/vscode-node/parts/vscodeWorkspace.ts', + 'extensions/copilot/src/extension/intents/node/editCodeIntent.ts', + 'extensions/copilot/src/extension/intents/node/editCodeStep.ts', + 'extensions/copilot/src/extension/intents/node/fixIntent.ts', + 'extensions/copilot/src/extension/intents/node/newIntent.ts', + 'extensions/copilot/src/extension/intents/node/searchIntent.ts', + 'extensions/copilot/src/extension/languageContextProvider/vscode-node/languageContextProviderService.ts', + 'extensions/copilot/src/extension/linkify/common/commands.ts', + 'extensions/copilot/src/extension/linkify/common/responseStreamWithLinkification.ts', + 'extensions/copilot/src/extension/linkify/test/node/util.ts', + 'extensions/copilot/src/extension/log/vscode-node/loggingActions.ts', + 'extensions/copilot/src/extension/log/vscode-node/requestLogTree.ts', + 'extensions/copilot/src/extension/mcp/test/vscode-node/util.ts', + 'extensions/copilot/src/extension/mcp/vscode-node/commands.ts', + 'extensions/copilot/src/extension/mcp/vscode-node/nuget.ts', + 'extensions/copilot/src/extension/onboardDebug/node/copilotDebugWorker/rpc.ts', + 'extensions/copilot/src/extension/onboardDebug/node/parseLaunchConfigFromResponse.ts', + 'extensions/copilot/src/extension/onboardDebug/vscode-node/copilotDebugCommandHandle.ts', + 'extensions/copilot/src/extension/prompt/common/toolCallRound.ts', + 'extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts', + 'extensions/copilot/src/extension/prompt/node/chatParticipantTelemetry.ts', + 'extensions/copilot/src/extension/prompt/node/editGeneration.ts', + 'extensions/copilot/src/extension/prompt/node/intents.ts', + 'extensions/copilot/src/extension/prompt/node/todoListContextProvider.ts', + 'extensions/copilot/src/extension/prompt/vscode-node/endpointProviderImpl.ts', + 'extensions/copilot/src/extension/prompt/vscode-node/requestLoggerImpl.ts', + 'extensions/copilot/src/extension/prompts/node/agent/promptRegistry.ts', + 'extensions/copilot/src/extension/prompts/node/base/promptElement.ts', + 'extensions/copilot/src/extension/prompts/node/base/promptRenderer.ts', + 'extensions/copilot/src/extension/prompts/node/test/utils.ts', + 'extensions/copilot/src/extension/replay/common/chatReplayResponses.ts', + 'extensions/copilot/src/extension/replay/node/replayParser.ts', + 'extensions/copilot/src/extension/replay/vscode-node/replayDebugSession.ts', + 'extensions/copilot/src/extension/review/node/githubReviewAgent.ts', + 'extensions/copilot/src/extension/test/node/services.ts', + 'extensions/copilot/src/extension/test/vscode-node/extension.test.ts', + 'extensions/copilot/src/extension/test/vscode-node/sanity.sanity-test.ts', + 'extensions/copilot/src/extension/test/vscode-node/session.test.ts', + 'extensions/copilot/src/extension/tools/common/toolSchemaNormalizer.ts', + 'extensions/copilot/src/extension/tools/common/toolsService.ts', + 'extensions/copilot/src/extension/typescriptContext/common/serverProtocol.ts', + 'extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/baseContextProviders.ts', + 'extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/contextProvider.ts', + 'extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/protocol.ts', + 'extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/typescripts.ts', + 'extensions/copilot/src/extension/typescriptContext/serverPlugin/src/common/utils.ts', + 'extensions/copilot/src/extension/typescriptContext/vscode-node/inspector.ts', + 'extensions/copilot/src/extension/typescriptContext/vscode-node/languageContextService.ts', + 'extensions/copilot/src/extension/workspaceRecorder/vscode-node/workspaceListenerService.ts', + 'extensions/copilot/src/extension/workspaceSemanticSearch/node/semanticSearchTextSearchProvider.ts', + 'extensions/copilot/src/lib/node/chatLibMain.ts', + 'extensions/copilot/src/platform/authentication/test/node/simulationTestCopilotTokenManager.ts', + 'extensions/copilot/src/platform/chat/common/blockedExtensionService.ts', + 'extensions/copilot/src/platform/chunking/common/chunkingEndpointClientImpl.ts', + 'extensions/copilot/src/platform/commands/common/mockRunCommandExecutionService.ts', + 'extensions/copilot/src/platform/commands/common/runCommandExecutionService.ts', + 'extensions/copilot/src/platform/commands/vscode/runCommandExecutionServiceImpl.ts', + 'extensions/copilot/src/platform/configuration/common/configurationService.ts', + 'extensions/copilot/src/platform/configuration/common/validator.ts', + 'extensions/copilot/src/platform/configuration/test/common/inMemoryConfigurationService.ts', + 'extensions/copilot/src/platform/configuration/vscode/configurationServiceImpl.ts', + 'extensions/copilot/src/platform/customInstructions/common/customInstructionsService.ts', + 'extensions/copilot/src/platform/debug/vscode/debugOutputListener.ts', + 'extensions/copilot/src/platform/diff/node/diffWorkerMain.ts', + 'extensions/copilot/src/platform/editing/common/notebookDocumentSnapshot.ts', + 'extensions/copilot/src/platform/editing/common/textDocumentSnapshot.ts', + 'extensions/copilot/src/platform/embeddings/common/embeddingsGrouper.ts', + 'extensions/copilot/src/platform/embeddings/common/embeddingsIndex.ts', + 'extensions/copilot/src/platform/embeddings/common/remoteEmbeddingsComputer.ts', + 'extensions/copilot/src/platform/endpoint/node/modelMetadataFetcher.ts', + 'extensions/copilot/src/platform/endpoint/test/node/openaiCompatibleEndpoint.ts', + 'extensions/copilot/src/platform/env/common/packagejson.ts', + 'extensions/copilot/src/platform/extensions/common/extensionsService.ts', + 'extensions/copilot/src/platform/filesystem/common/fileSystemService.ts', + 'extensions/copilot/src/platform/github/common/githubService.ts', + 'extensions/copilot/src/platform/github/common/nullOctokitServiceImpl.ts', + 'extensions/copilot/src/platform/inlineEdits/common/dataTypes/edit.ts', + 'extensions/copilot/src/platform/inlineEdits/common/dataTypes/textEditLengthHelper/length.ts', + 'extensions/copilot/src/platform/inlineEdits/common/editReason.ts', + 'extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts', + 'extensions/copilot/src/platform/inlineEdits/common/utils/observable.ts', + 'extensions/copilot/src/platform/languages/common/languageDiagnosticsService.ts', + 'extensions/copilot/src/platform/log/common/logExecTime.ts', + 'extensions/copilot/src/platform/log/common/logService.ts', + 'extensions/copilot/src/platform/log/vscode/outputChannelLogTarget.ts', + 'extensions/copilot/src/platform/nesFetch/common/completionsFetchService.ts', + 'extensions/copilot/src/platform/nesFetch/node/completionsFetchServiceImpl.ts', + 'extensions/copilot/src/platform/networking/common/fetch.ts', + 'extensions/copilot/src/platform/networking/common/fetcherService.ts', + 'extensions/copilot/src/platform/networking/common/networking.ts', + 'extensions/copilot/src/platform/networking/common/openai.ts', + 'extensions/copilot/src/platform/networking/node/baseFetchFetcher.ts', + 'extensions/copilot/src/platform/networking/node/chatStream.ts', + 'extensions/copilot/src/platform/networking/node/fetcherFallback.ts', + 'extensions/copilot/src/platform/networking/node/nodeFetchFetcher.ts', + 'extensions/copilot/src/platform/networking/node/nodeFetcher.ts', + 'extensions/copilot/src/platform/networking/node/stream.ts', + 'extensions/copilot/src/platform/networking/node/test/nodeFetcherService.ts', + 'extensions/copilot/src/platform/networking/vscode-node/electronFetcher.ts', + 'extensions/copilot/src/platform/networking/vscode-node/fetcherServiceImpl.ts', + 'extensions/copilot/src/platform/notification/common/notificationService.ts', + 'extensions/copilot/src/platform/notification/vscode/notificationServiceImpl.ts', + 'extensions/copilot/src/platform/openai/node/fetch.ts', + 'extensions/copilot/src/platform/parser/node/nodes.ts', + 'extensions/copilot/src/platform/parser/node/parserServiceImpl.ts', + 'extensions/copilot/src/platform/parser/node/parserWorker.ts', + 'extensions/copilot/src/platform/parser/node/treeSitterQueries.ts', + 'extensions/copilot/src/platform/remoteCodeSearch/common/githubCodeSearchService.ts', + 'extensions/copilot/src/platform/remoteSearch/node/codeOrDocsSearchClientImpl.ts', + 'extensions/copilot/src/platform/review/vscode/reviewServiceImpl.ts', + 'extensions/copilot/src/platform/scopeSelection/vscode-node/scopeSelectionImpl.ts', + 'extensions/copilot/src/platform/snippy/common/snippyTypes.ts', + 'extensions/copilot/src/platform/survey/vscode/surveyServiceImpl.ts', + 'extensions/copilot/src/platform/tasks/vscode/tasksService.ts', + 'extensions/copilot/src/platform/telemetry/common/failingTelemetryReporter.ts', + 'extensions/copilot/src/platform/telemetry/common/telemetryData.ts', + 'extensions/copilot/src/platform/telemetry/node/azureInsightsReporter.ts', + 'extensions/copilot/src/platform/telemetry/node/spyingTelemetryService.ts', + 'extensions/copilot/src/platform/terminal/common/terminalService.ts', + 'extensions/copilot/src/platform/terminal/vscode/terminalServiceImpl.ts', + 'extensions/copilot/src/platform/test/common/endpointTestFixtures.ts', + 'extensions/copilot/src/platform/test/common/testExtensionsService.ts', + 'extensions/copilot/src/platform/test/node/extensionContext.ts', + 'extensions/copilot/src/platform/test/node/fetcher.ts', + 'extensions/copilot/src/platform/test/node/services.ts', + 'extensions/copilot/src/platform/test/node/simulationWorkspace.ts', + 'extensions/copilot/src/platform/test/node/telemetry.ts', + 'extensions/copilot/src/platform/test/node/testWorkbenchService.ts', + 'extensions/copilot/src/platform/testing/common/nullWorkspaceMutationManager.ts', + 'extensions/copilot/src/platform/thinking/common/thinking.ts', + 'extensions/copilot/src/platform/tokenizer/node/tikTokenizerWorker.ts', + 'extensions/copilot/src/platform/tokenizer/node/tokenizer.ts', + 'extensions/copilot/src/platform/workbench/common/workbenchService.ts', + 'extensions/copilot/src/platform/workbench/vscode/workbenchServiceImpt.ts', + 'extensions/copilot/src/platform/workspaceChunkSearch/node/nullWorkspaceFileIndex.ts', + 'extensions/copilot/src/platform/workspaceChunkSearch/node/tfidfChunkSearch.ts', + 'extensions/copilot/src/platform/workspaceChunkSearch/node/workspaceFileIndex.ts', + 'extensions/copilot/src/platform/workspaceRecorder/common/resolvedRecording/resolvedRecording.ts', + 'extensions/copilot/src/util/common/async.ts', + 'extensions/copilot/src/util/common/cache.ts', + 'extensions/copilot/src/util/common/chatResponseStreamImpl.ts', + 'extensions/copilot/src/util/common/debounce.ts', + 'extensions/copilot/src/util/common/debugValueEditorGlobals.ts', + 'extensions/copilot/src/util/common/diff.ts', + 'extensions/copilot/src/util/common/progress.ts', + 'extensions/copilot/src/util/common/test/shims/chatTypes.ts', + 'extensions/copilot/src/util/common/test/shims/editing.ts', + 'extensions/copilot/src/util/common/test/shims/l10n.ts', + 'extensions/copilot/src/util/common/test/shims/notebookDocument.ts', + 'extensions/copilot/src/util/common/test/shims/vscodeTypesShim.ts', + 'extensions/copilot/src/util/common/test/simpleMock.ts', + 'extensions/copilot/src/util/common/timeTravelScheduler.ts', + 'extensions/copilot/src/util/common/types.ts', + 'extensions/copilot/src/util/node/worker.ts', + ], + languageOptions: { + parser: tseslint.parser, + }, + plugins: { + '@typescript-eslint': tseslint.plugin, + }, + rules: { + '@typescript-eslint/no-explicit-any': [ + 'warn', + { + 'fixToUnknown': true + } + ] + } + }, + // copilot extension - chatLibMain exception + { + files: [ + 'extensions/copilot/src/lib/node/chatLibMain.ts', + ], + rules: { + 'import/no-restricted-paths': 'off' + } + }, // Allow querySelector/querySelectorAll in test files - it's acceptable for test assertions { files: [ diff --git a/extensions/copilot/.esbuild.ts b/extensions/copilot/.esbuild.ts index 68dd47035789c..2377e0cc6e58b 100644 --- a/extensions/copilot/.esbuild.ts +++ b/extensions/copilot/.esbuild.ts @@ -45,11 +45,11 @@ const baseNodeBuildOptions = { ...(isDev ? [] : ['dotenv', 'source-map-support']) ], platform: 'node', - mainFields: ["module", "main"], // needed for jsonc-parser, + mainFields: ['module', 'main'], // needed for jsonc-parser, define: { 'process.env.APPLICATIONINSIGHTS_CONFIGURATION_CONTENT': JSON.stringify(JSON.stringify({ - proxyHttpUrl: "", - proxyHttpsUrl: "" + proxyHttpUrl: '', + proxyHttpsUrl: '' })) }, } satisfies esbuild.BuildOptions; @@ -232,7 +232,7 @@ const nodeSimulationBuildOptions = { const nodeSimulationWorkbenchUIBuildOptions = { ...baseNodeBuildOptions, platform: 'browser', // @ulugbekna: important to target 'browser' for correct bundling using 'window' - mainFields: ["browser", "module", "main"], + mainFields: ['browser', 'module', 'main'], entryPoints: [ { in: './test/simulation/workbench/simulationWorkbench.tsx', out: 'simulationWorkbench' }, ], @@ -277,8 +277,8 @@ const typeScriptServerPluginBuildOptions = { sourcesContent: false, treeShaking: true, external: [ - "typescript", - "typescript/lib/tsserverlibrary" + 'typescript', + 'typescript/lib/tsserverlibrary', ], entryPoints: [ { in: './src/extension/typescriptContext/serverPlugin/src/node/main.ts', out: 'main' }, diff --git a/extensions/copilot/.eslint-ignore b/extensions/copilot/.eslint-ignore deleted file mode 100644 index 86f3fb98c20ae..0000000000000 --- a/extensions/copilot/.eslint-ignore +++ /dev/null @@ -1,31 +0,0 @@ -node_modules -dist -coverage -lint-staged.config.js -vite.config.ts -**/vscode.proposed.*.ts -**/vscode.d.ts -.esbuild/extension.esbuild.ts -test/simulation/fixtures/** -test/scenarios/** -.simulation/** -.eslintplugin/** -chat-lib/** -test/aml/out/** -.vscode-test/** - -# ignore vs -src/util/vs/** - -# ignore test fixtures -src/platform/parser/test/node/fixtures/** -src/extension/test/node/fixtures/** -src/extension/prompts/node/test/fixtures/** - -# TypeScript server plugin -src/extension/typescriptContext/serverPlugin/fixtures/** -src/extension/typescriptContext/serverPlugin/lib/** -src/extension/typescriptContext/serverPlugin/dist/** - -# Ignore Built test-extension -.vscode/extensions/test-extension/dist/** diff --git a/extensions/copilot/.eslintplugin/no-unexternalized-strings.ts b/extensions/copilot/.eslintplugin/no-unexternalized-strings.ts deleted file mode 100644 index a7065cb2a0db9..0000000000000 --- a/extensions/copilot/.eslintplugin/no-unexternalized-strings.ts +++ /dev/null @@ -1,192 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; -import * as eslint from 'eslint'; -import type * as ESTree from 'estree'; - -function isStringLiteral(node: TSESTree.Node | ESTree.Node | null | undefined): node is TSESTree.StringLiteral { - return !!node && node.type === AST_NODE_TYPES.Literal && typeof node.value === 'string'; -} - -function isDoubleQuoted(node: TSESTree.StringLiteral): boolean { - return node.raw[0] === '"' && node.raw[node.raw.length - 1] === '"'; -} - -/** - * Enable bulk fixing double-quoted strings to single-quoted strings with the --fix eslint flag - * - * Disabled by default as this is often not the desired fix. Instead the string should be localized. However it is - * useful for bulk conversations of existing code. - */ -const enableDoubleToSingleQuoteFixes = false; - - -export default new class NoUnexternalizedStrings implements eslint.Rule.RuleModule { - - private static _rNlsKeys = /^[_a-zA-Z0-9][ .\-_a-zA-Z0-9]*$/; - - readonly meta: eslint.Rule.RuleMetaData = { - messages: { - doubleQuoted: 'Only use double-quoted strings for externalized strings.', - badKey: 'The key \'{{key}}\' doesn\'t conform to a valid localize identifier.', - duplicateKey: 'Duplicate key \'{{key}}\' with different message value.', - badMessage: 'Message argument to \'{{message}}\' must be a string literal.' - }, - schema: false, - fixable: enableDoubleToSingleQuoteFixes ? 'code' : undefined, - }; - - create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener { - - const externalizedStringLiterals = new Map(); - const doubleQuotedStringLiterals = new Set(); - - function collectDoubleQuotedStrings(node: ESTree.Literal) { - if (isStringLiteral(node) && isDoubleQuoted(node)) { - doubleQuotedStringLiterals.add(node); - } - } - - function visitLocalizeCall(node: TSESTree.CallExpression) { - - // localize(key, message) - const [keyNode, messageNode] = node.arguments; - - // (1) - // extract key so that it can be checked later - let key: string | undefined; - if (isStringLiteral(keyNode)) { - doubleQuotedStringLiterals.delete(keyNode); - key = keyNode.value; - - } else if (keyNode.type === AST_NODE_TYPES.ObjectExpression) { - for (const property of keyNode.properties) { - if (property.type === AST_NODE_TYPES.Property && !property.computed) { - if (property.key.type === AST_NODE_TYPES.Identifier && property.key.name === 'key') { - if (isStringLiteral(property.value)) { - doubleQuotedStringLiterals.delete(property.value); - key = property.value.value; - break; - } - } - } - } - } - if (typeof key === 'string') { - let array = externalizedStringLiterals.get(key); - if (!array) { - array = []; - externalizedStringLiterals.set(key, array); - } - array.push({ call: node, message: messageNode }); - } - - // (2) - // remove message-argument from doubleQuoted list and make - // sure it is a string-literal - doubleQuotedStringLiterals.delete(messageNode); - if (!isStringLiteral(messageNode)) { - context.report({ - loc: messageNode.loc, - messageId: 'badMessage', - data: { message: context.getSourceCode().getText(node as ESTree.Node) } - }); - } - } - - function visitL10NCall(node: TSESTree.CallExpression) { - - // localize(key, message) - const [messageNode] = (node as TSESTree.CallExpression).arguments; // remove message-argument from doubleQuoted list and make - // sure it is a string-literal - if (isStringLiteral(messageNode)) { - doubleQuotedStringLiterals.delete(messageNode); - } else if (messageNode.type === AST_NODE_TYPES.ObjectExpression) { - for (const prop of messageNode.properties) { - if (prop.type === AST_NODE_TYPES.Property) { - if (prop.key.type === AST_NODE_TYPES.Identifier && prop.key.name === 'message') { - doubleQuotedStringLiterals.delete(prop.value); - break; - } - } - } - } - } - - function reportBadStringsAndBadKeys() { - // (1) - // report all strings that are in double quotes - for (const node of doubleQuotedStringLiterals) { - context.report({ - loc: node.loc, - messageId: 'doubleQuoted', - fix: enableDoubleToSingleQuoteFixes ? (fixer) => { - // Get the raw string content, unescaping any escaped quotes - const content = (node as ESTree.SimpleLiteral).raw! - .slice(1, -1) - .replace(/(? 1) { - for (let i = 1; i < values.length; i++) { - if (context.getSourceCode().getText(values[i - 1].message as ESTree.Node) !== context.getSourceCode().getText(values[i].message as ESTree.Node)) { - context.report({ loc: values[i].call.loc, messageId: 'duplicateKey', data: { key } }); - } - } - } - } - } - - return { - ['Literal']: (node: ESTree.Literal) => collectDoubleQuotedStrings(node), - ['ExpressionStatement[directive] Literal:exit']: (node: TSESTree.Literal) => doubleQuotedStringLiterals.delete(node), - - // localize(...) - ['CallExpression[callee.type="MemberExpression"][callee.object.name="nls"][callee.property.name="localize"]:exit']: (node: TSESTree.CallExpression) => visitLocalizeCall(node), - - // localize2(...) - ['CallExpression[callee.type="MemberExpression"][callee.object.name="nls"][callee.property.name="localize2"]:exit']: (node: TSESTree.CallExpression) => visitLocalizeCall(node), - - // vscode.l10n.t(...) - ['CallExpression[callee.type="MemberExpression"][callee.object.property.name="l10n"][callee.property.name="t"]:exit']: (node: TSESTree.CallExpression) => visitL10NCall(node), - - // l10n.t(...) - ['CallExpression[callee.object.name="l10n"][callee.property.name="t"]:exit']: (node: TSESTree.CallExpression) => visitL10NCall(node), - - ['CallExpression[callee.name="localize"][arguments.length>=2]:exit']: (node: TSESTree.CallExpression) => visitLocalizeCall(node), - ['CallExpression[callee.name="localize2"][arguments.length>=2]:exit']: (node: TSESTree.CallExpression) => visitLocalizeCall(node), - ['Program:exit']: reportBadStringsAndBadKeys, - }; - } -}; diff --git a/extensions/copilot/.vscode/extensions/test-extension/bootstrap.ts b/extensions/copilot/.vscode/extensions/test-extension/bootstrap.ts index 1b51b71a8c039..53a4e27a73ddb 100644 --- a/extensions/copilot/.vscode/extensions/test-extension/bootstrap.ts +++ b/extensions/copilot/.vscode/extensions/test-extension/bootstrap.ts @@ -5,4 +5,5 @@ import * as vscode from 'vscode'; +// eslint-disable-next-line local/code-no-any-casts (globalThis).projectRoot = vscode.workspace.workspaceFolders?.at(0)?.uri.fsPath ?? __dirname; diff --git a/extensions/copilot/.vscode/extensions/test-extension/main.ts b/extensions/copilot/.vscode/extensions/test-extension/main.ts index b1d3c3d271653..8c0fedbeac8ca 100644 --- a/extensions/copilot/.vscode/extensions/test-extension/main.ts +++ b/extensions/copilot/.vscode/extensions/test-extension/main.ts @@ -218,7 +218,7 @@ export async function activate(context: vscode.ExtensionContext) { vscode.commands.executeCommand( 'debug-value-editor.debug-and-send-request', { - launchConfigName: "Test Visualization Runner STests", + launchConfigName: 'Test Visualization Runner STests', args: args, revealAvailablePropertiesView: true, } diff --git a/extensions/copilot/.vscode/extensions/visualization-runner/entry.js b/extensions/copilot/.vscode/extensions/visualization-runner/entry.js index 7fc321e99f65a..5ea90eb572ec0 100644 --- a/extensions/copilot/.vscode/extensions/visualization-runner/entry.js +++ b/extensions/copilot/.vscode/extensions/visualization-runner/entry.js @@ -4,15 +4,15 @@ *--------------------------------------------------------------------------------------------*/ require('tsx/cjs'); -const { enableHotReload, hotRequire } = require("@hediet/node-reload"); +const { enableHotReload, hotRequire } = require('@hediet/node-reload'); enableHotReload({ entryModule: module }); /** - * @param {import("vscode").ExtensionContext} context + * @param {import('vscode').ExtensionContext} context */ function activate(context) { - context.subscriptions.push(hotRequire(module, "./extension", ext => new ext.Extension())); + context.subscriptions.push(hotRequire(module, './extension', ext => new ext.Extension())); } module.exports = { activate }; diff --git a/extensions/copilot/.vscode/extensions/visualization-runner/extension.ts b/extensions/copilot/.vscode/extensions/visualization-runner/extension.ts index ad36bec57683d..19766ce69d10f 100644 --- a/extensions/copilot/.vscode/extensions/visualization-runner/extension.ts +++ b/extensions/copilot/.vscode/extensions/visualization-runner/extension.ts @@ -42,7 +42,7 @@ export class Extension extends Disposable { title: 'Visualize Test', command: 'debug-value-editor.debug-and-send-request', arguments: [{ - launchConfigName: "Test Visualization Runner", + launchConfigName: 'Test Visualization Runner', args: { fileName: document.fileName, path: t.path, diff --git a/extensions/copilot/eslint.config.mjs b/extensions/copilot/eslint.config.mjs deleted file mode 100644 index 7edfb1982d931..0000000000000 --- a/extensions/copilot/eslint.config.mjs +++ /dev/null @@ -1,538 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import stylisticEslint from '@stylistic/eslint-plugin'; -import tsEslint from '@typescript-eslint/eslint-plugin'; -import tsParser from '@typescript-eslint/parser'; -import importEslint from 'eslint-plugin-import'; -import jsdocEslint from 'eslint-plugin-jsdoc'; -import fs from 'fs'; -import { builtinModules } from 'module'; -import path from 'path'; -import tseslint from 'typescript-eslint'; -import { fileURLToPath } from 'url'; - -import headerEslint from 'eslint-plugin-header'; -headerEslint.rules.header.meta.schema = false; - -import * as localEslint from './.eslintplugin/index.ts'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const ignores = fs.readFileSync(path.join(__dirname, '.eslint-ignore'), 'utf8') - .toString() - .split(/\r\n|\n/) - .filter(line => line && !line.startsWith('#')); - -export default tseslint.config( - // Global ignores - { - ignores: [ - ...ignores, - '!**/.eslint-plugin-local/**/*' - ], - }, - // All js/ts files - { - files: [ - '**/*.{js,jsx,mjs,cjs,ts,tsx}', - ], - ignores: [ - './src/extension/completions-core/**/testdata/*', - ], - languageOptions: { - parser: tsParser, - }, - plugins: { - '@stylistic': stylisticEslint, - 'header': headerEslint, - }, - rules: { - 'indent': [ - 'error', - 'tab', - { - ignoredNodes: [ - 'SwitchCase', - 'ClassDeclaration', - 'TemplateLiteral *', // Conflicts with tsfmt - 'CallExpression > ArrowFunctionExpression', // Conflicts with tsfmt - 'CallExpression > ArrowFunctionExpression > BlockStatement', // Conflicts with tsfmt - 'NewExpression > ArrowFunctionExpression', // Conflicts with tsfmt - 'NewExpression > ArrowFunctionExpression > BlockStatement' // Conflicts with tsfmt - ] - } - ], - 'constructor-super': 'error', - 'curly': 'error', - 'eqeqeq': 'error', - 'prefer-const': [ - 'error', - { - destructuring: 'all' - } - ], - 'no-buffer-constructor': 'error', - 'no-caller': 'error', - 'no-case-declarations': 'error', - 'no-debugger': 'error', - 'no-duplicate-case': 'error', - 'no-duplicate-imports': 'error', - 'no-eval': 'error', - 'no-async-promise-executor': 'error', - 'no-extra-semi': 'error', - 'no-new-wrappers': 'error', - 'no-redeclare': 'off', - 'no-sparse-arrays': 'error', - 'no-throw-literal': 'error', - 'no-unsafe-finally': 'error', - 'no-unused-labels': 'error', - 'no-restricted-globals': [ - 'error', - 'name', - 'length', - 'event', - 'closed', - 'external', - 'status', - 'origin', - 'orientation', - 'context' - ], // non-complete list of globals that are easy to access unintentionally - 'no-var': 'error', - 'semi': 'error', - 'header/header': [ - 'error', - 'block', - [ - '---------------------------------------------------------------------------------------------', - ' * Copyright (c) Microsoft Corporation. All rights reserved.', - ' * Licensed under the MIT License. See License.txt in the project root for license information.', - ' *--------------------------------------------------------------------------------------------' - ] - ] - }, - settings: { - 'import/resolver': { - typescript: { - extensions: ['.ts', '.tsx'] - } - } - }, - }, - // All ts files - { - files: [ - '**/*.{ts,tsx}', - ], - languageOptions: { - parser: tsParser, - }, - plugins: { - '@typescript-eslint': tsEslint, - '@stylistic': stylisticEslint, - 'jsdoc': jsdocEslint, - }, - rules: { - 'jsdoc/no-types': 'error', - '@stylistic/member-delimiter-style': 'error', - '@typescript-eslint/naming-convention': [ - 'error', - { - selector: 'class', - format: ['PascalCase'] - } - ], - }, - settings: { - 'import/resolver': { - typescript: { - extensions: ['.ts', '.tsx'] - } - } - }, - }, - // Main extension sources - { - files: [ - 'src/**/*.{ts,tsx}', - 'test/**/*.{ts,tsx}', - ], - ignores: [ - '**/.esbuild.ts', - './src/extension/completions-core/vscode-node/bridge/src/completionsTelemetryServiceBridge.ts', - ], - languageOptions: { - parser: tseslint.parser, - }, - plugins: { - 'import': importEslint, - 'local': localEslint, - }, - rules: { - 'no-restricted-imports': [ - 'error', - // node: builtins - ...builtinModules, - // node: dependencies - '@humanwhocodes/gitignore-to-minimatch', - '@vscode/extension-telemetry', - 'applicationinsights', - 'ignore', - 'isbinaryfile', - 'minimatch', - 'source-map-support', - 'vscode-tas-client', - 'web-tree-sitter' - ], - 'import/no-restricted-paths': [ - 'error', - { - zones: [ - { - target: '**/common/**', - from: [ - '**/vscode/**', - '**/node/**', - '**/vscode-node/**', - '**/worker/**', - '**/vscode-worker/**' - ] - }, - { - target: '**/vscode/**', - from: [ - '**/node/**', - '**/vscode-node/**', - '**/worker/**', - '**/vscode-worker/**' - ] - }, - { - target: '**/node/**', - from: [ - '**/vscode/**', - '**/vscode-node/**', - '**/worker/**', - '**/vscode-worker/**' - ] - }, - { - target: '**/vscode-node/**', - from: [ - '**/worker/**', - '**/vscode-worker/**' - ] - }, - { - target: '**/worker/**', - from: [ - '**/vscode/**', - '**/node/**', - '**/vscode-node/**', - '**/vscode-worker/**' - ] - }, - { - target: '**/vscode-worker/**', - from: [ - '**/node/**', - '**/vscode-node/**' - ] - }, - { - target: './src/', - from: './test/' - }, - { - target: './src/shared-fetch-utils', - from: ['./src/extension', './src/platform', './src/util', './src/lib'] - }, - { - target: './src/util', - from: ['./src/platform', './src/extension'] - }, - { - target: './src/platform', - from: ['./src/extension'] - }, - { - target: ['./test', '!./test/base/extHostContext/*.ts'], - from: ['**/vscode-node/**', '**/vscode-worker/**'] - }, - { - target: 'src/!(lib)/**', - from: './src/lib' - } - ] - } - ], - 'local/no-instanceof-uri': ['error'], - 'local/no-test-imports': ['error'], - 'local/no-runtime-import': [ - 'error', - { - test: ['vscode'], - 'src/**/common/**/*': ['vscode'], - 'src/**/node/**/*': ['vscode'] - } - ], - 'local/no-funny-filename': ['error'], - 'local/no-bad-gdpr-comment': ['error'], - 'local/no-gdpr-event-name-mismatch': ['error'], - 'local/no-unlayered-files': ['error'], - 'local/no-restricted-copilot-pr-string': [ - 'error', - { - className: 'GitHubPullRequestProviders', - string: 'Generate with Copilot' - } - ], - 'local/no-nls-localize': ['error'], - 'local/no-unexternalized-strings': ['error'], - } - }, - { - files: ['**/{vscode-node,node}/**/*.ts', '**/{vscode-node,node}/**/*.tsx'], - rules: { - 'no-restricted-imports': 'off' - } - }, - { - files: ['**/*.js'], - rules: { - 'jsdoc/no-types': 'off' - } - }, - { - files: ['src/extension/**/*.tsx'], - rules: { - 'local/no-missing-linebreak': 'error' - } - }, - { - files: ['**/*.test.ts', '**/*.test.tsx'], - rules: { - 'local/no-test-only': 'error' - } - }, - { - files: [ - 'test/**', - 'src/vscodeTypes.ts', - 'script/**', - 'src/extension/*.d.ts', - 'build/**' - ], - rules: { - 'local/no-unlayered-files': 'off', - 'no-restricted-imports': 'off' - } - }, - // no-explicit-any - { - files: [ - 'src/**/*.ts', - ], - ignores: [ - 'src/util/vs/**/*.ts', // vendored code - 'src/**/*.spec.ts', // allow in tests - './src/extension/byok/common/anthropicMessageConverter.ts', - './src/extension/byok/common/geminiFunctionDeclarationConverter.ts', - './src/extension/byok/common/geminiMessageConverter.ts', - './src/extension/byok/vscode-node/anthropicProvider.ts', - './src/extension/byok/vscode-node/geminiNativeProvider.ts', - './src/extension/byok/vscode-node/ollamaProvider.ts', - './src/extension/chatSessions/vscode-node/copilotCloudSessionContentBuilder.ts', - './src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts', - './src/extension/codeBlocks/node/codeBlockProcessor.ts', - './src/extension/codeBlocks/vscode-node/provider.ts', - './src/extension/configuration/vscode-node/configurationMigration.ts', - './src/extension/context/node/resolvers/genericInlineIntentInvocation.ts', - './src/extension/context/node/resolvers/genericPanelIntentInvocation.ts', - './src/extension/context/node/resolvers/inlineFixIntentInvocation.ts', - './src/extension/context/node/resolvers/promptWorkspaceLabels.ts', - './src/extension/contextKeys/vscode-node/contextKeys.contribution.ts', - './src/extension/conversation/vscode-node/userActions.ts', - './src/extension/extension/vscode/services.ts', - './src/extension/inlineChat/node/rendererVisualization.ts', - './src/extension/inlineChat/vscode-node/inlineChatCommands.ts', - './src/extension/inlineEdits/common/observableWorkspaceRecordingReplayer.ts', - './src/extension/inlineEdits/vscode-node/parts/vscodeWorkspace.ts', - './src/extension/intents/node/editCodeIntent.ts', - './src/extension/intents/node/editCodeStep.ts', - './src/extension/intents/node/fixIntent.ts', - './src/extension/intents/node/newIntent.ts', - './src/extension/intents/node/searchIntent.ts', - './src/extension/languageContextProvider/vscode-node/languageContextProviderService.ts', - './src/extension/linkify/common/commands.ts', - './src/extension/linkify/common/responseStreamWithLinkification.ts', - './src/extension/linkify/test/node/util.ts', - './src/extension/log/vscode-node/loggingActions.ts', - './src/extension/log/vscode-node/requestLogTree.ts', - './src/extension/mcp/test/vscode-node/util.ts', - './src/extension/mcp/vscode-node/commands.ts', - './src/extension/mcp/vscode-node/nuget.ts', - './src/extension/onboardDebug/node/copilotDebugWorker/rpc.ts', - './src/extension/onboardDebug/node/parseLaunchConfigFromResponse.ts', - './src/extension/onboardDebug/vscode-node/copilotDebugCommandHandle.ts', - './src/extension/prompt/common/toolCallRound.ts', - './src/extension/prompt/node/chatMLFetcher.ts', - './src/extension/prompt/node/chatParticipantTelemetry.ts', - './src/extension/prompt/node/editGeneration.ts', - './src/extension/prompt/node/intents.ts', - './src/extension/prompt/node/todoListContextProvider.ts', - './src/extension/prompt/vscode-node/endpointProviderImpl.ts', - './src/extension/prompt/vscode-node/requestLoggerImpl.ts', - './src/extension/prompts/node/agent/promptRegistry.ts', - './src/extension/prompts/node/base/promptElement.ts', - './src/extension/prompts/node/base/promptRenderer.ts', - './src/extension/prompts/node/test/utils.ts', - './src/extension/replay/common/chatReplayResponses.ts', - './src/extension/replay/node/replayParser.ts', - './src/extension/replay/vscode-node/replayDebugSession.ts', - './src/extension/review/node/githubReviewAgent.ts', - './src/extension/test/node/services.ts', - './src/extension/test/vscode-node/extension.test.ts', - './src/extension/test/vscode-node/sanity.sanity-test.ts', - './src/extension/test/vscode-node/session.test.ts', - './src/extension/tools/common/toolSchemaNormalizer.ts', - './src/extension/tools/common/toolsService.ts', - './src/extension/typescriptContext/common/serverProtocol.ts', - './src/extension/typescriptContext/serverPlugin/src/common/baseContextProviders.ts', - './src/extension/typescriptContext/serverPlugin/src/common/contextProvider.ts', - './src/extension/typescriptContext/serverPlugin/src/common/protocol.ts', - './src/extension/typescriptContext/serverPlugin/src/common/typescripts.ts', - './src/extension/typescriptContext/serverPlugin/src/common/utils.ts', - './src/extension/typescriptContext/vscode-node/inspector.ts', - './src/extension/typescriptContext/vscode-node/languageContextService.ts', - './src/extension/workspaceRecorder/vscode-node/workspaceListenerService.ts', - './src/extension/workspaceSemanticSearch/node/semanticSearchTextSearchProvider.ts', - './src/lib/node/chatLibMain.ts', - './src/platform/authentication/test/node/simulationTestCopilotTokenManager.ts', - './src/platform/chat/common/blockedExtensionService.ts', - './src/platform/chunking/common/chunkingEndpointClientImpl.ts', - './src/platform/commands/common/mockRunCommandExecutionService.ts', - './src/platform/commands/common/runCommandExecutionService.ts', - './src/platform/commands/vscode/runCommandExecutionServiceImpl.ts', - './src/platform/configuration/common/configurationService.ts', - './src/platform/configuration/common/validator.ts', - './src/platform/configuration/test/common/inMemoryConfigurationService.ts', - './src/platform/configuration/vscode/configurationServiceImpl.ts', - './src/platform/customInstructions/common/customInstructionsService.ts', - './src/platform/debug/vscode/debugOutputListener.ts', - './src/platform/diff/node/diffWorkerMain.ts', - './src/platform/editing/common/notebookDocumentSnapshot.ts', - './src/platform/editing/common/textDocumentSnapshot.ts', - './src/platform/embeddings/common/embeddingsGrouper.ts', - './src/platform/embeddings/common/embeddingsIndex.ts', - './src/platform/embeddings/common/remoteEmbeddingsComputer.ts', - './src/platform/endpoint/node/modelMetadataFetcher.ts', - './src/platform/endpoint/test/node/openaiCompatibleEndpoint.ts', - './src/platform/env/common/packagejson.ts', - './src/platform/extensions/common/extensionsService.ts', - './src/platform/filesystem/common/fileSystemService.ts', - './src/platform/github/common/githubService.ts', - './src/platform/github/common/nullOctokitServiceImpl.ts', - './src/platform/inlineEdits/common/dataTypes/edit.ts', - './src/platform/inlineEdits/common/dataTypes/textEditLengthHelper/length.ts', - './src/platform/inlineEdits/common/editReason.ts', - './src/platform/inlineEdits/common/statelessNextEditProvider.ts', - './src/platform/inlineEdits/common/utils/observable.ts', - './src/platform/languages/common/languageDiagnosticsService.ts', - './src/platform/log/common/logExecTime.ts', - './src/platform/log/common/logService.ts', - './src/platform/log/vscode/outputChannelLogTarget.ts', - './src/platform/nesFetch/common/completionsFetchService.ts', - './src/platform/nesFetch/node/completionsFetchServiceImpl.ts', - './src/platform/networking/common/fetch.ts', - './src/platform/networking/common/fetcherService.ts', - './src/platform/networking/common/networking.ts', - './src/platform/networking/common/openai.ts', - './src/platform/networking/node/baseFetchFetcher.ts', - './src/platform/networking/node/chatStream.ts', - './src/platform/networking/node/fetcherFallback.ts', - './src/platform/networking/node/nodeFetchFetcher.ts', - './src/platform/networking/node/nodeFetcher.ts', - './src/platform/networking/node/stream.ts', - './src/platform/networking/node/test/nodeFetcherService.ts', - './src/platform/networking/vscode-node/electronFetcher.ts', - './src/platform/networking/vscode-node/fetcherServiceImpl.ts', - './src/platform/notification/common/notificationService.ts', - './src/platform/notification/vscode/notificationServiceImpl.ts', - './src/platform/openai/node/fetch.ts', - './src/platform/parser/node/nodes.ts', - './src/platform/parser/node/parserServiceImpl.ts', - './src/platform/parser/node/parserWorker.ts', - './src/platform/parser/node/treeSitterQueries.ts', - './src/platform/remoteCodeSearch/common/githubCodeSearchService.ts', - './src/platform/remoteSearch/node/codeOrDocsSearchClientImpl.ts', - './src/platform/review/vscode/reviewServiceImpl.ts', - './src/platform/scopeSelection/vscode-node/scopeSelectionImpl.ts', - './src/platform/snippy/common/snippyTypes.ts', - './src/platform/survey/vscode/surveyServiceImpl.ts', - './src/platform/tasks/vscode/tasksService.ts', - './src/platform/telemetry/common/failingTelemetryReporter.ts', - './src/platform/telemetry/common/telemetryData.ts', - './src/platform/telemetry/node/azureInsightsReporter.ts', - './src/platform/telemetry/node/spyingTelemetryService.ts', - './src/platform/terminal/common/terminalService.ts', - './src/platform/terminal/vscode/terminalServiceImpl.ts', - './src/platform/test/common/endpointTestFixtures.ts', - './src/platform/test/common/testExtensionsService.ts', - './src/platform/test/node/extensionContext.ts', - './src/platform/test/node/fetcher.ts', - './src/platform/test/node/services.ts', - './src/platform/test/node/simulationWorkspace.ts', - './src/platform/test/node/telemetry.ts', - './src/platform/test/node/testWorkbenchService.ts', - './src/platform/testing/common/nullWorkspaceMutationManager.ts', - './src/platform/thinking/common/thinking.ts', - './src/platform/tokenizer/node/tikTokenizerWorker.ts', - './src/platform/tokenizer/node/tokenizer.ts', - './src/platform/workbench/common/workbenchService.ts', - './src/platform/workbench/vscode/workbenchServiceImpt.ts', - './src/platform/workspaceChunkSearch/node/nullWorkspaceFileIndex.ts', - './src/platform/workspaceChunkSearch/node/tfidfChunkSearch.ts', - './src/platform/workspaceChunkSearch/node/workspaceFileIndex.ts', - './src/platform/workspaceRecorder/common/resolvedRecording/resolvedRecording.ts', - './src/util/common/async.ts', - './src/util/common/cache.ts', - './src/util/common/chatResponseStreamImpl.ts', - './src/util/common/debounce.ts', - './src/util/common/debugValueEditorGlobals.ts', - './src/util/common/diff.ts', - './src/util/common/progress.ts', - './src/util/common/test/shims/chatTypes.ts', - './src/util/common/test/shims/editing.ts', - './src/util/common/test/shims/l10n.ts', - './src/util/common/test/shims/notebookDocument.ts', - './src/util/common/test/shims/vscodeTypesShim.ts', - './src/util/common/test/simpleMock.ts', - './src/util/common/timeTravelScheduler.ts', - './src/util/common/types.ts', - './src/util/node/worker.ts', - ], - languageOptions: { - parser: tseslint.parser, - }, - plugins: { - '@typescript-eslint': tseslint.plugin, - }, - rules: { - '@typescript-eslint/no-explicit-any': [ - 'warn', - { - 'fixToUnknown': true - } - ] - } - }, - { - files: ['./src/lib/node/chatLibMain.ts'], - rules: { - 'import/no-restricted-paths': 'off' - } - }, -); diff --git a/extensions/copilot/lint-staged.config.js b/extensions/copilot/lint-staged.config.js deleted file mode 100644 index 0b3be885ebcb9..0000000000000 --- a/extensions/copilot/lint-staged.config.js +++ /dev/null @@ -1,30 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -const ESLint = require('eslint').ESLint; - -const removeIgnoredFiles = async (files) => { - const eslint = new ESLint(); - const isIgnored = await Promise.all( - files.map((file) => { - return eslint.isPathIgnored(file); - }) - ); - const filteredFiles = files.filter((_, i) => !isIgnored[i]); - return filteredFiles.join(' '); -}; - -module.exports = { - '!({.esbuild.ts,test/simulation/fixtures/**,test/scenarios/**,.vscode/extensions/**,**/vscode.proposed.*})*{.ts,.js,.tsx}': async (files) => { - const filesToLint = await removeIgnoredFiles(files); - if (!filesToLint) { - return []; - } - return [ - `node --experimental-strip-types ../../build/lib/formatter.ts --replace ${filesToLint}`, - `eslint --max-warnings=0 ${filesToLint}` - ]; - }, -}; diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index 67aa8920df2aa..b405e5cc5378f 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -13,7 +13,7 @@ "@anthropic-ai/claude-agent-sdk": "0.2.112", "@anthropic-ai/sdk": "^0.82.0", "@github/blackbird-external-ingest-utils": "^0.3.0", - "@github/copilot": "^1.0.38", + "@github/copilot": "1.0.34", "@google/genai": "^1.22.0", "@humanwhocodes/gitignore-to-minimatch": "1.0.2", "@microsoft/tiktokenizer": "^1.0.10", @@ -102,17 +102,10 @@ "dotenv": "^17.2.0", "electron": "^39.8.5", "esbuild": "0.27.2", - "eslint": "^9.30.0", - "eslint-import-resolver-typescript": "^4.4.4", - "eslint-plugin-header": "^3.1.1", - "eslint-plugin-import": "^2.32.0", - "eslint-plugin-jsdoc": "^51.3.4", - "eslint-plugin-no-only-tests": "^3.3.0", "fastq": "^1.19.1", "glob": "^11.1.0", "js-yaml": "^4.1.1", "keyv": "^5.3.2", - "lint-staged": "15.2.9", "minimist": "^1.2.8", "mobx": "^6.13.7", "mobx-react-lite": "^4.1.0", @@ -138,7 +131,6 @@ "ts-dedent": "^2.2.0", "tsx": "^4.20.3", "typescript": "^5.8.3", - "typescript-eslint": "^8.36.0", "vite-plugin-top-level-await": "^1.5.0", "vite-plugin-wasm": "^3.5.0", "vitest": "^3.0.5", @@ -155,15 +147,6 @@ "vscode": "^1.119.0" } }, - "node_modules/@aashutoshrathi/word-wrap": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", - "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -843,40 +826,6 @@ "semver": "bin/semver.js" } }, - "node_modules/@emnapi/core": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz", - "integrity": "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.0.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", - "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.2.tgz", - "integrity": "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emotion/hash": { "version": "0.9.2", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", @@ -884,23 +833,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@es-joy/jsdoccomment": { - "version": "0.52.0", - "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.52.0.tgz", - "integrity": "sha512-BXuN7BII+8AyNtn57euU2Yxo9yA/KUDNzrpXyi3pfqKmBhhysR6ZWOebFh3vyPoqA3/j1SOvGgucElMGwlXing==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.8", - "@typescript-eslint/types": "^8.34.1", - "comment-parser": "1.4.1", - "esquery": "^1.6.0", - "jsdoc-type-pratt-parser": "~4.1.0" - }, - "engines": { - "node": ">=20.11.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -1372,178 +1304,6 @@ "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@eslint/config-array": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", - "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.6", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", - "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", - "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@eslint/eslintrc/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@eslint/js": { - "version": "9.30.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.1.tgz", - "integrity": "sha512-zXhuECFlyep42KZUhWjfvsmXGX39W8K8LFb8AWXM9gSV9dQB+MrJGLKvW6Zw0Ggnbpw0VHTtrhFXYe3Gym18jg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz", - "integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.15.1", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", - "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, "node_modules/@floating-ui/core": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.2.tgz", @@ -3202,26 +2962,26 @@ "license": "MIT" }, "node_modules/@github/copilot": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.38.tgz", - "integrity": "sha512-GjtKCiFczeKuECOuxkBkJYb8estSnhxgh4iQ9BTkWg4y3EWYl2VaMCXCu9KkVPf/fwy/URt1l8Rf4M4tZxVZAA==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.34.tgz", + "integrity": "sha512-jFYulj1v00b3j43Er9+WwhZ/XldGq7+gti2s2pRhrdPwYEd1PMvscDZwRa/1iUBz/XQ5HUGac1tD8P7+VUpWjg==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.38", - "@github/copilot-darwin-x64": "1.0.38", - "@github/copilot-linux-arm64": "1.0.38", - "@github/copilot-linux-x64": "1.0.38", - "@github/copilot-win32-arm64": "1.0.38", - "@github/copilot-win32-x64": "1.0.38" + "@github/copilot-darwin-arm64": "1.0.34", + "@github/copilot-darwin-x64": "1.0.34", + "@github/copilot-linux-arm64": "1.0.34", + "@github/copilot-linux-x64": "1.0.34", + "@github/copilot-win32-arm64": "1.0.34", + "@github/copilot-win32-x64": "1.0.34" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.38.tgz", - "integrity": "sha512-JyzyQ/VUC30QBOnOoqBbfAlMbIycKVqIOepeTdArNk+oER8qfQ9LqQPxA6FDqCQl3GAMclzqZGL9jK7I2WldhA==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.34.tgz", + "integrity": "sha512-g94EhSLd3a6fckZ6xb/zP2DZJZEx7kONWdOoDiHXUtSqc4RiZ7OBq1EwT4WrPY1lsmy9sioJIcZSGzJd0C1M7Q==", "cpu": [ "arm64" ], @@ -3235,9 +2995,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.38.tgz", - "integrity": "sha512-2Wv/4KPY2XC6JRGvJzavrk/RBmbH3Z5pNZZslL0BW2+AeZsoYqmVrA/1pxUs+KSVaGDC420dqS7uZ6u/mg23oQ==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.34.tgz", + "integrity": "sha512-tIgFEZV0ohCF/VgTODJWre3xURsvEd+6IPN/HPKWxG6AXtJOxzjlr5kLYYdPHdNlHNmSxGQw8fWsN2FZ4nyDdw==", "cpu": [ "x64" ], @@ -3251,9 +3011,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.38.tgz", - "integrity": "sha512-s+rNuvL3pKkZ6orZZoKcsbNDlu79f6/EBj5ovo2pJ6iBI3YMNwUM8AZq9pcFUpZCaLJ6E7GGZoujRMbpjKP/wQ==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.34.tgz", + "integrity": "sha512-feqjEetrlqBUhYskIsPmwACQOWO99cvRpKwIFl3OlEjWoj+//HA7yXh49UIe0gD8wQUI8hy05uVz3K2/xti2nQ==", "cpu": [ "arm64" ], @@ -3267,9 +3027,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.38.tgz", - "integrity": "sha512-8aAXJ0Qv+4naW4FcsqQNzgGykaiYe5q7ZO55ZuUMQ92ZY+Kae5kTttwiZ325T9CdeNHVT9f+aMx8gAGVWxfvFg==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.34.tgz", + "integrity": "sha512-3l0rZZqmceklHizJaaO+Iy2PsAZpVZS9Mn9VYnVcY/8Yzt4Y2hmXSFcKVfc4l+JlhFsPs7trhMdIkfwkjaKPLg==", "cpu": [ "x64" ], @@ -3283,9 +3043,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.38.tgz", - "integrity": "sha512-M7Da1h25IsnYyw9LBCatxgQUsu+C5+xJsHMZeR8dnxRF/kt75Ksqk1+pWp8oBk1BqK9ahTgb4zFqCfFDhmUO3w==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.34.tgz", + "integrity": "sha512-06kEJO3iyohmAqF4iIbOxOfWLFSIpLDJ1L1oEHRtouMrH2Ll1wrUjsoQT1gXgBOv7rifl25qx/Avx5zKqvuORw==", "cpu": [ "arm64" ], @@ -3299,9 +3059,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.38.tgz", - "integrity": "sha512-PhAUhWRbg718Uc+a6RXqoGN8fGYD+Rj5FWQPQ3rbmgZitPRzlT/WrQaWj0BenRERUjLshPuxSm1GJUB4Kyc/7Q==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.34.tgz", + "integrity": "sha512-QLL8pS4q2TTyQbClEXxqXtQGPr4lk+pwc8hPMUL7iw7HGDOvs1WCLMT1ZSDPPcxSrTnR/dURX5za1NMA8uF/fw==", "cpu": [ "x64" ], @@ -3433,44 +3193,6 @@ "hono": "^4" } }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, "node_modules/@humanwhocodes/gitignore-to-minimatch": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@humanwhocodes/gitignore-to-minimatch/-/gitignore-to-minimatch-1.0.2.tgz", @@ -3480,33 +3202,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, "node_modules/@img/sharp-darwin-arm64": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", @@ -4242,19 +3937,6 @@ } } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.11.tgz", - "integrity": "sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.9.0" - } - }, "node_modules/@nevware21/ts-async": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/@nevware21/ts-async/-/ts-async-0.5.4.tgz", @@ -5749,13 +5431,6 @@ "win32" ] }, - "node_modules/@rtsao/scc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", - "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", - "dev": true, - "license": "MIT" - }, "node_modules/@secretlint/config-creator": { "version": "10.1.1", "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-10.1.1.tgz", @@ -6410,17 +6085,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@tybys/wasm-util": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", - "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -6570,13 +6234,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/json5": { - "version": "0.0.29", - "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/keyv": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", @@ -7128,308 +6785,39 @@ "node": ">=18.0.0" } }, - "node_modules/@unrs/resolver-binding-android-arm-eabi": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.9.2.tgz", - "integrity": "sha512-tS+lqTU3N0kkthU+rYp0spAYq15DU8ld9kXkaKg9sbQqJNF+WPMuNHZQGCgdxrUOEO0j22RKMwRVhF1HTl+X8A==", - "cpu": [ - "arm" - ], + "node_modules/@vitest/coverage-v8": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.9.2.tgz", - "integrity": "sha512-MffGiZULa/KmkNjHeuuflLVqfhqLv1vZLm8lWIyeADvlElJ/GLSOkoUX+5jf4/EGtfwrNFcEaB8BRas03KT0/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-arm64": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.9.2.tgz", - "integrity": "sha512-dzJYK5rohS1sYl1DHdJ3mwfwClJj5BClQnQSyAgEfggbUwA9RlROQSSbKBLqrGfsiC/VyrDPtbO8hh56fnkbsQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.9.2.tgz", - "integrity": "sha512-gaIMWK+CWtXcg9gUyznkdV54LzQ90S3X3dn8zlh+QR5Xy7Y+Efqw4Rs4im61K1juy4YNb67vmJsCDAGOnIeffQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.9.2.tgz", - "integrity": "sha512-S7QpkMbVoVJb0xwHFwujnwCAEDe/596xqY603rpi/ioTn9VDgBHnCCxh+UFrr5yxuMH+dliHfjwCZJXOPJGPnw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.9.2.tgz", - "integrity": "sha512-+XPUMCuCCI80I46nCDFbGum0ZODP5NWGiwS3Pj8fOgsG5/ctz+/zzuBlq/WmGa+EjWZdue6CF0aWWNv84sE1uw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.9.2.tgz", - "integrity": "sha512-sqvUyAd1JUpwbz33Ce2tuTLJKM+ucSsYpPGl2vuFwZnEIg0CmdxiZ01MHQ3j6ExuRqEDUCy8yvkDKvjYFPb8Zg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.9.2.tgz", - "integrity": "sha512-UYA0MA8ajkEDCFRQdng/FVx3F6szBvk3EPnkTTQuuO9lV1kPGuTB+V9TmbDxy5ikaEgyWKxa4CI3ySjklZ9lFA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.9.2.tgz", - "integrity": "sha512-P/CO3ODU9YJIHFqAkHbquKtFst0COxdphc8TKGL5yCX75GOiVpGqd1d15ahpqu8xXVsqP4MGFP2C3LRZnnL5MA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.9.2.tgz", - "integrity": "sha512-uKStFlOELBxBum2s1hODPtgJhY4NxYJE9pAeyBgNEzHgTqTiVBPjfTlPFJkfxyTjQEuxZbbJlJnMCrRgD7ubzw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.9.2.tgz", - "integrity": "sha512-LkbNnZlhINfY9gK30AHs26IIVEZ9PEl9qOScYdmY2o81imJYI4IMnJiW0vJVtXaDHvBvxeAgEy5CflwJFIl3tQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.9.2.tgz", - "integrity": "sha512-vI+e6FzLyZHSLFNomPi+nT+qUWN4YSj8pFtQZSFTtmgFoxqB6NyjxSjAxEC1m93qn6hUXhIsh8WMp+fGgxCoRg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.9.2.tgz", - "integrity": "sha512-sSO4AlAYhSM2RAzBsRpahcJB1msc6uYLAtP6pesPbZtptF8OU/CbCPhSRW6cnYOGuVmEmWVW5xVboAqCnWTeHQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.9.2.tgz", - "integrity": "sha512-jkSkwch0uPFva20Mdu8orbQjv2A3G88NExTN2oPTI1AJ+7mZfYW3cDCTyoH6OnctBKbBVeJCEqh0U02lTkqD5w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-musl": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.9.2.tgz", - "integrity": "sha512-Uk64NoiTpQbkpl+bXsbeyOPRpUoMdcUqa+hDC1KhMW7aN1lfW8PBlBH4mJ3n3Y47dYE8qi0XTxy1mBACruYBaw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.9.2.tgz", - "integrity": "sha512-EpBGwkcjDicjR/ybC0g8wO5adPNdVuMrNalVgYcWi+gYtC1XYNuxe3rufcO7dA76OHGeVabcO6cSkPJKVcbCXQ==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.11" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.9.2.tgz", - "integrity": "sha512-EdFbGn7o1SxGmN6aZw9wAkehZJetFPao0VGZ9OMBwKx6TkvDuj6cNeLimF/Psi6ts9lMOe+Dt6z19fZQ9Ye2fw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.9.2.tgz", - "integrity": "sha512-JY9hi1p7AG+5c/dMU8o2kWemM8I6VZxfGwn1GCtf3c5i+IKcMo2NQ8OjZ4Z3/itvY/Si3K10jOBQn7qsD/whUA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.9.2.tgz", - "integrity": "sha512-ryoo+EB19lMxAd80ln9BVf8pdOAxLb97amrQ3SFN9OCRn/5M5wvwDgAe4i8ZjhpbiHoDeP8yavcTEnpKBo7lZg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@vitest/coverage-v8": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", - "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.3.0", - "@bcoe/v8-coverage": "^1.0.2", - "ast-v8-to-istanbul": "^0.3.3", - "debug": "^4.4.1", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-lib-source-maps": "^5.0.6", - "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.17", - "magicast": "^0.3.5", - "std-env": "^3.9.0", - "test-exclude": "^7.0.1", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "3.2.4", - "vitest": "3.2.4" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } - } + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } }, "node_modules/@vitest/coverage-v8/node_modules/@bcoe/v8-coverage": { "version": "1.0.2", @@ -8518,22 +7906,6 @@ } } }, - "node_modules/ansi-escapes": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", - "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -8871,15 +8243,6 @@ "streamx": "^2.15.0" } }, - "node_modules/are-docs-informative": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", - "integrity": "sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==", - "dev": true, - "engines": { - "node": ">=14" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -8912,97 +8275,14 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/array-includes": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", - "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.0", - "es-object-atoms": "^1.1.1", - "get-intrinsic": "^1.3.0", - "is-string": "^1.1.1", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.findlastindex": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", - "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-shim-unscopables": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flat": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", - "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", - "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.1", + "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", @@ -9953,16 +9233,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/chai": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", @@ -10146,77 +9416,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cli-truncate": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", - "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", - "dev": true, - "license": "MIT", - "dependencies": { - "slice-ansi": "^5.0.0", - "string-width": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/cli-truncate/node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true, - "license": "MIT" - }, - "node_modules/cli-truncate/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -10331,13 +9530,6 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, - "license": "MIT" - }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -10361,16 +9553,6 @@ "node": ">=18" } }, - "node_modules/comment-parser": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz", - "integrity": "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12.0.0" - } - }, "node_modules/compress-commons": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", @@ -10974,12 +10156,6 @@ "node": ">=4.0.0" } }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true - }, "node_modules/deepmerge-ts": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", @@ -11524,19 +10700,6 @@ "node": ">=6" } }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/err-code": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", @@ -11676,19 +10839,6 @@ "node": ">= 0.4" } }, - "node_modules/es-shim-unscopables": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", - "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-to-primitive": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", @@ -11804,501 +10954,87 @@ "source-map": "~0.6.1" } }, - "node_modules/eslint": { - "version": "9.30.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.30.1.tgz", - "integrity": "sha512-zmxXPNMOXmwm9E0yQLi5uqXHs7uq2UIiqEKo3Gq+3fwo1XrJ+hijAZImyF7hclW3E6oHz43Yk3RP8at6OTKflQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.3.0", - "@eslint/core": "^0.14.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.30.1", - "@eslint/plugin-kit": "^0.3.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } + "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint-import-context": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.8.tgz", - "integrity": "sha512-bq+F7nyc65sKpZGT09dY0S0QrOnQtuDVIfyTGQ8uuvtMIF7oHp6CEP3mouN0rrnYF3Jqo6Ke0BfU/5wASZue1w==", + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "get-tsconfig": "^4.10.1", - "stable-hash-x": "^0.1.1" + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" }, "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/eslint-import-context" - }, - "peerDependencies": { - "unrs-resolver": "^1.0.0" + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "peerDependenciesMeta": { - "unrs-resolver": { - "optional": true - } + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint-import-context/node_modules/stable-hash-x": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.1.1.tgz", - "integrity": "sha512-l0x1D6vhnsNUGPFVDx45eif0y6eedVC8nm5uACTrVFJFtl2mLRW17aWtVyxFCpn5t94VUPkjU8vSLwIuwwqtJQ==", + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, "engines": { - "node": ">=12.0.0" + "node": ">=4" } }, - "node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, - "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" } }, - "node_modules/eslint-import-resolver-node/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, + "license": "MIT", "dependencies": { - "ms": "^2.1.1" + "@types/estree": "^1.0.0" } }, - "node_modules/eslint-import-resolver-typescript": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.4.tgz", - "integrity": "sha512-1iM2zeBvrYmUNTj2vSC/90JTHDth+dfOfiNKkxApWRsTJYNrc8rOdxxIf5vazX+BiAXTeOT0UvWpGI/7qIWQOw==", - "dev": true, - "license": "ISC", - "dependencies": { - "debug": "^4.4.1", - "eslint-import-context": "^0.1.8", - "get-tsconfig": "^4.10.1", - "is-bun-module": "^2.0.0", - "stable-hash-x": "^0.2.0", - "tinyglobby": "^0.2.14", - "unrs-resolver": "^1.7.11" - }, - "engines": { - "node": "^16.17.0 || >=18.6.0" - }, - "funding": { - "url": "https://opencollective.com/eslint-import-resolver-typescript" - }, - "peerDependencies": { - "eslint": "*", - "eslint-plugin-import": "*", - "eslint-plugin-import-x": "*" - }, - "peerDependenciesMeta": { - "eslint-plugin-import": { - "optional": true - }, - "eslint-plugin-import-x": { - "optional": true - } - } - }, - "node_modules/eslint-module-utils": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", - "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7" - }, - "engines": { - "node": ">=4" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/eslint-module-utils/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-header": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-header/-/eslint-plugin-header-3.1.1.tgz", - "integrity": "sha512-9vlKxuJ4qf793CmeeSrZUvVClw6amtpghq3CuWcB5cUNnWHQhgcqy5eF8oVKFk1G3Y/CbchGfEaw3wiIJaNmVg==", - "dev": true, - "peerDependencies": { - "eslint": ">=7.7.0" - } - }, - "node_modules/eslint-plugin-import": { - "version": "2.32.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", - "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rtsao/scc": "^1.1.0", - "array-includes": "^3.1.9", - "array.prototype.findlastindex": "^1.2.6", - "array.prototype.flat": "^1.3.3", - "array.prototype.flatmap": "^1.3.3", - "debug": "^3.2.7", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.12.1", - "hasown": "^2.0.2", - "is-core-module": "^2.16.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "object.groupby": "^1.0.3", - "object.values": "^1.2.1", - "semver": "^6.3.1", - "string.prototype.trimend": "^1.0.9", - "tsconfig-paths": "^3.15.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" - } - }, - "node_modules/eslint-plugin-import/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import/node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/eslint-plugin-import/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/eslint-plugin-import/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/eslint-plugin-jsdoc": { - "version": "51.3.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-51.3.4.tgz", - "integrity": "sha512-maz6qa95+sAjMr9m5oRyfejc+mnyQWsWSe9oyv9371bh4/T0kWOMryJNO4h8rEd97wo/9lbzwi3OOX4rDhnAzg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@es-joy/jsdoccomment": "~0.52.0", - "are-docs-informative": "^0.0.2", - "comment-parser": "1.4.1", - "debug": "^4.4.1", - "escape-string-regexp": "^4.0.0", - "espree": "^10.4.0", - "esquery": "^1.6.0", - "parse-imports-exports": "^0.2.4", - "semver": "^7.7.2", - "spdx-expression-parse": "^4.0.0" - }, - "engines": { - "node": ">=20.11.0" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" - } - }, - "node_modules/eslint-plugin-jsdoc/node_modules/spdx-expression-parse": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", - "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/eslint-plugin-no-only-tests": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-no-only-tests/-/eslint-plugin-no-only-tests-3.3.0.tgz", - "integrity": "sha512-brcKcxGnISN2CcVhXJ/kEQlNa0MEfGRtwKtWA16SkqXHKitaKIMrfemJKLKX1YqDU5C/5JY3PvZXd5jEW04e0Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=5.0.0" - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/eslint/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, "engines": { "node": ">=0.10.0" @@ -12323,13 +11059,6 @@ "node": ">=6" } }, - "node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", - "dev": true, - "license": "MIT" - }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -12578,19 +11307,6 @@ "node": ">= 6" } }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true - }, "node_modules/fast-uri": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.5.tgz", @@ -12682,19 +11398,6 @@ } } }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -12760,37 +11463,6 @@ "flat": "cli.js" } }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flat-cache/node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "dev": true, - "license": "ISC" - }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -13203,18 +11875,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/glob/node_modules/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", @@ -13243,19 +11903,6 @@ "node": ">=10.0" } }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/globalthis": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", @@ -13737,23 +12384,6 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "dev": true }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/import-in-the-middle": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.4.2.tgz", @@ -13781,6 +12411,7 @@ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, + "optional": true, "engines": { "node": ">=0.8.19" } @@ -13947,16 +12578,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-bun-module": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", - "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.7.1" - } - }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -14052,19 +12673,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-fullwidth-code-point": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", - "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-generator-function": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", @@ -14570,16 +13178,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsdoc-type-pratt-parser": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.1.0.tgz", - "integrity": "sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -14637,31 +13235,12 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true - }, "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true, - "optional": true - }, - "node_modules/json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.0" - }, - "bin": { - "json5": "lib/cli.js" - } + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "optional": true }, "node_modules/jsonc-parser": { "version": "3.3.1", @@ -14866,478 +13445,156 @@ "debug": "^4.0.1", "koa-compose": "^4.1.0" }, - "engines": { - "node": ">= 7.6.0" - } - }, - "node_modules/koa-send": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/koa-send/-/koa-send-5.0.1.tgz", - "integrity": "sha512-tmcyQ/wXXuxpDxyNXv5yNNkdAMdFRqwtegBXUaowiQzUKqJehttS0x2j0eOZDQAyloAth5w6wwBImnFzkUz3pQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "http-errors": "^1.7.3", - "resolve-path": "^1.4.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/koa-send/node_modules/depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/koa-send/node_modules/http-errors": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", - "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "depd": "~1.1.2", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/koa-send/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/koa-static": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/koa-static/-/koa-static-5.0.0.tgz", - "integrity": "sha512-UqyYyH5YEXaJrf9S8E23GoJFQZXkBVJ9zYYMPGz919MSX1KuvAcycIuS0ci150HCoPf4XQVhQ84Qf8xRPWxFaQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.1.0", - "koa-send": "^5.0.0" - }, - "engines": { - "node": ">= 7.6.0" - } - }, - "node_modules/koa-static/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/koa/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/koa/node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/lazystream": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", - "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "readable-stream": "^2.0.5" - }, - "engines": { - "node": ">= 0.6.3" - } - }, - "node_modules/leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lie": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", - "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", - "dev": true, - "dependencies": { - "immediate": "~3.0.5" - } - }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/lines-and-columns": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-2.0.4.tgz", - "integrity": "sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", - "dependencies": { - "uc.micro": "^2.0.0" - } - }, - "node_modules/lint-staged": { - "version": "15.2.9", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.2.9.tgz", - "integrity": "sha512-BZAt8Lk3sEnxw7tfxM7jeZlPRuT4M68O0/CwZhhaw6eeWu0Lz5eERE3m386InivXB64fp/mDID452h48tvKlRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "~5.3.0", - "commander": "~12.1.0", - "debug": "~4.3.6", - "execa": "~8.0.1", - "lilconfig": "~3.1.2", - "listr2": "~8.2.4", - "micromatch": "~4.0.7", - "pidtree": "~0.6.0", - "string-argv": "~0.3.2", - "yaml": "~2.5.0" - }, - "bin": { - "lint-staged": "bin/lint-staged.js" - }, - "engines": { - "node": ">=18.12.0" - }, - "funding": { - "url": "https://opencollective.com/lint-staged" - } - }, - "node_modules/lint-staged/node_modules/chalk": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", - "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", - "dev": true, - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/lint-staged/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/lint-staged/node_modules/execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/lint-staged/node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true, - "engines": { - "node": ">=16.17.0" - } - }, - "node_modules/lint-staged/node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/npm-run-path": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.1.0.tgz", - "integrity": "sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==", - "dev": true, - "dependencies": { - "path-key": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">= 7.6.0" } }, - "node_modules/lint-staged/node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "node_modules/koa-send": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/koa-send/-/koa-send-5.0.1.tgz", + "integrity": "sha512-tmcyQ/wXXuxpDxyNXv5yNNkdAMdFRqwtegBXUaowiQzUKqJehttS0x2j0eOZDQAyloAth5w6wwBImnFzkUz3pQ==", "dev": true, + "license": "MIT", "dependencies": { - "mimic-fn": "^4.0.0" + "debug": "^4.1.1", + "http-errors": "^1.7.3", + "resolve-path": "^1.4.0" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 8" } }, - "node_modules/lint-staged/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "node_modules/koa-send/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", "dev": true, + "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.6" } }, - "node_modules/lint-staged/node_modules/strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "node_modules/koa-send/node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", "dev": true, - "engines": { - "node": ">=12" + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">= 0.6" } }, - "node_modules/lint-staged/node_modules/yaml": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", - "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==", + "node_modules/koa-send/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", "dev": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, + "license": "MIT", "engines": { - "node": ">= 14" + "node": ">= 0.6" } }, - "node_modules/listr2": { - "version": "8.2.5", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.2.5.tgz", - "integrity": "sha512-iyAZCeyD+c1gPyE9qpFu8af0Y+MRtmKOncdGoA2S5EY8iFq99dmmvkNnHiWo+pj0s7yH7l3KPIgee77tKpXPWQ==", + "node_modules/koa-static": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/koa-static/-/koa-static-5.0.0.tgz", + "integrity": "sha512-UqyYyH5YEXaJrf9S8E23GoJFQZXkBVJ9zYYMPGz919MSX1KuvAcycIuS0ci150HCoPf4XQVhQ84Qf8xRPWxFaQ==", "dev": true, "license": "MIT", "dependencies": { - "cli-truncate": "^4.0.0", - "colorette": "^2.0.20", - "eventemitter3": "^5.0.1", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^9.0.0" + "debug": "^3.1.0", + "koa-send": "^5.0.0" }, "engines": { - "node": ">=18.0.0" + "node": ">= 7.6.0" } }, - "node_modules/listr2/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "node_modules/koa-static/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "dependencies": { + "ms": "^2.1.1" } }, - "node_modules/listr2/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "node_modules/koa/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">= 0.6" } }, - "node_modules/listr2/node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true, - "license": "MIT" - }, - "node_modules/listr2/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "node_modules/koa/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" + "mime-db": "^1.54.0" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.6" } }, - "node_modules/listr2/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "readable-stream": "^2.0.5" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": ">= 0.6.3" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "engines": { + "node": ">=6" } }, - "node_modules/listr2/node_modules/wrap-ansi": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", - "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", "dev": true, - "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, + "immediate": "~3.0.5" + } + }, + "node_modules/lines-and-columns": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-2.0.4.tgz", + "integrity": "sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dependencies": { + "uc.micro": "^2.0.0" } }, "node_modules/load-json-file": { @@ -15467,185 +13724,41 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true - }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "dev": true, - "license": "MIT" - }, - "node_modules/lodash.truncate": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", - "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.zip": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.zip/-/lodash.zip-4.2.0.tgz", - "integrity": "sha512-C7IOaBBK/0gMORRBd8OETNx3kmOkgIWIPvyDpZSCTwUrpYmgZwJkjZeOD8ww4xbOUOs4/attY+pciKvadNfFbg==", - "dev": true, - "license": "MIT" - }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", - "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-escapes": "^7.0.0", - "cli-cursor": "^5.0.0", - "slice-ansi": "^7.1.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/log-update/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/log-update/node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true, - "license": "MIT" - }, - "node_modules/log-update/node_modules/is-fullwidth-code-point": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", - "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/slice-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", - "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } + "license": "MIT" }, - "node_modules/log-update/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "license": "MIT" }, - "node_modules/log-update/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "node_modules/lodash.zip": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.zip/-/lodash.zip-4.2.0.tgz", + "integrity": "sha512-C7IOaBBK/0gMORRBd8OETNx3kmOkgIWIPvyDpZSCTwUrpYmgZwJkjZeOD8ww4xbOUOs4/attY+pciKvadNfFbg==", "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } + "license": "MIT" }, - "node_modules/log-update/node_modules/wrap-ansi": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", - "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", "dev": true, - "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" }, "engines": { - "node": ">=18" + "node": ">=10" }, "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/loglevel": { @@ -15872,12 +13985,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -16595,22 +14702,6 @@ "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==", "dev": true }, - "node_modules/napi-postinstall": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.2.4.tgz", - "integrity": "sha512-ZEzHJwBhZ8qQSbknHqYcdtQVr8zUgGyM/q6h6qAyhtyVMNrSgDhrC4disf03dYW0e+czXyLnZINnCTEkWy0eJg==", - "dev": true, - "license": "MIT", - "bin": { - "napi-postinstall": "lib/cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/napi-postinstall" - } - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -17448,59 +15539,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object.fromentries": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", - "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.groupby": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", - "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.values": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", - "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -17588,23 +15626,6 @@ } } }, - "node_modules/optionator": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", - "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", - "dev": true, - "dependencies": { - "@aashutoshrathi/word-wrap": "^1.2.3", - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/ora": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", @@ -17862,29 +15883,6 @@ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "dev": true }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-imports-exports": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/parse-imports-exports/-/parse-imports-exports-0.2.4.tgz", - "integrity": "sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parse-statements": "1.0.11" - } - }, "node_modules/parse-json": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-7.1.1.tgz", @@ -17936,13 +15934,6 @@ "semver": "bin/semver" } }, - "node_modules/parse-statements": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/parse-statements/-/parse-statements-1.0.11.tgz", - "integrity": "sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==", - "dev": true, - "license": "MIT" - }, "node_modules/parse5": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", @@ -18129,18 +16120,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pidtree": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", - "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", - "dev": true, - "bin": { - "pidtree": "bin/pidtree.js" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/pify": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", @@ -18294,15 +16273,6 @@ "node": ">=10" } }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/prettier": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", @@ -18538,16 +16508,6 @@ "once": "^1.3.1" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/punycode.js": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", @@ -18969,16 +16929,6 @@ "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", "dev": true }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/resolve-path": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/resolve-path/-/resolve-path-1.4.0.tgz", @@ -19129,13 +17079,6 @@ "node": ">=0.10.0" } }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" - }, "node_modules/rgb2hex": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/rgb2hex/-/rgb2hex-0.2.5.tgz", @@ -19935,36 +17878,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/slice-ansi": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", - "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.0.0", - "is-fullwidth-code-point": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -20185,16 +18098,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/stable-hash-x": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", - "integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/stack-chain": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/stack-chain/-/stack-chain-1.3.7.tgz", @@ -20280,15 +18183,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/string-argv": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", - "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", - "dev": true, - "engines": { - "node": ">=0.6.19" - } - }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -21159,19 +19053,6 @@ "node": ">=6.10" } }, - "node_modules/tsconfig-paths": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", - "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json5": "^0.0.29", - "json5": "^1.0.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -21714,18 +19595,6 @@ "node": "*" } }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -21888,29 +19757,6 @@ "node": ">=14.17" } }, - "node_modules/typescript-eslint": { - "version": "8.36.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.36.0.tgz", - "integrity": "sha512-fTCqxthY+h9QbEgSIBfL9iV6CvKDFuoxg6bHPNpJ9HIUzS+jy2lCEyCmGyZRWEBSaykqcDPf1SJ+BfCI8DRopA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.36.0", - "@typescript-eslint/parser": "8.36.0", - "@typescript-eslint/utils": "8.36.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" - } - }, "node_modules/uc.micro": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", @@ -22017,41 +19863,6 @@ "node": ">= 0.8" } }, - "node_modules/unrs-resolver": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.9.2.tgz", - "integrity": "sha512-VUyWiTNQD7itdiMuJy+EuLEErLj3uwX/EpHQF8EOf33Dq3Ju6VW1GXm+swk6+1h7a49uv9fKZ+dft9jU7esdLA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "napi-postinstall": "^0.2.4" - }, - "funding": { - "url": "https://opencollective.com/unrs-resolver" - }, - "optionalDependencies": { - "@unrs/resolver-binding-android-arm-eabi": "1.9.2", - "@unrs/resolver-binding-android-arm64": "1.9.2", - "@unrs/resolver-binding-darwin-arm64": "1.9.2", - "@unrs/resolver-binding-darwin-x64": "1.9.2", - "@unrs/resolver-binding-freebsd-x64": "1.9.2", - "@unrs/resolver-binding-linux-arm-gnueabihf": "1.9.2", - "@unrs/resolver-binding-linux-arm-musleabihf": "1.9.2", - "@unrs/resolver-binding-linux-arm64-gnu": "1.9.2", - "@unrs/resolver-binding-linux-arm64-musl": "1.9.2", - "@unrs/resolver-binding-linux-ppc64-gnu": "1.9.2", - "@unrs/resolver-binding-linux-riscv64-gnu": "1.9.2", - "@unrs/resolver-binding-linux-riscv64-musl": "1.9.2", - "@unrs/resolver-binding-linux-s390x-gnu": "1.9.2", - "@unrs/resolver-binding-linux-x64-gnu": "1.9.2", - "@unrs/resolver-binding-linux-x64-musl": "1.9.2", - "@unrs/resolver-binding-wasm32-wasi": "1.9.2", - "@unrs/resolver-binding-win32-arm64-msvc": "1.9.2", - "@unrs/resolver-binding-win32-ia32-msvc": "1.9.2", - "@unrs/resolver-binding-win32-x64-msvc": "1.9.2" - } - }, "node_modules/untildify": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", @@ -22062,16 +19873,6 @@ "node": ">=8" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, "node_modules/url-join": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index f6c0387754de1..f2ddeea145a82 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -6425,8 +6425,9 @@ "watch:tsc-extension-web": "tsc --noEmit --watch --project tsconfig.worker.json", "watch:tsc-simulation-workbench": "tsc --noEmit --watch --project test/simulation/workbench/tsconfig.json", "typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project test/simulation/workbench/tsconfig.json && tsc --noEmit --project tsconfig.worker.json && tsc --noEmit --project src/extension/completions-core/vscode-node/extension/src/copilotPanel/webView/tsconfig.json", - "lint": "eslint . --max-warnings=0", - "lint-staged": "eslint --max-warnings=0", + "lint": "npx eslint . --max-warnings=0", + "lint-staged": "npx eslint --max-warnings=0", + "tsfmt": "npx tsfmt -r --verify", "test": "npm-run-all test:*", "test:extension": "vscode-test", "test:sanity": "vscode-test --sanity", @@ -6500,17 +6501,10 @@ "dotenv": "^17.2.0", "electron": "^39.8.5", "esbuild": "0.27.2", - "eslint": "^9.30.0", - "eslint-import-resolver-typescript": "^4.4.4", - "eslint-plugin-header": "^3.1.1", - "eslint-plugin-import": "^2.32.0", - "eslint-plugin-jsdoc": "^51.3.4", - "eslint-plugin-no-only-tests": "^3.3.0", "fastq": "^1.19.1", "glob": "^11.1.0", "js-yaml": "^4.1.1", "keyv": "^5.3.2", - "lint-staged": "15.2.9", "minimist": "^1.2.8", "mobx": "^6.13.7", "mobx-react-lite": "^4.1.0", @@ -6536,7 +6530,6 @@ "ts-dedent": "^2.2.0", "tsx": "^4.20.3", "typescript": "^5.8.3", - "typescript-eslint": "^8.36.0", "vite-plugin-top-level-await": "^1.5.0", "vite-plugin-wasm": "^3.5.0", "vitest": "^3.0.5", @@ -6551,7 +6544,7 @@ "@anthropic-ai/claude-agent-sdk": "0.2.112", "@anthropic-ai/sdk": "^0.82.0", "@github/blackbird-external-ingest-utils": "^0.3.0", - "@github/copilot": "^1.0.38", + "@github/copilot": "1.0.34", "@google/genai": "^1.22.0", "@humanwhocodes/gitignore-to-minimatch": "1.0.2", "@microsoft/tiktokenizer": "^1.0.10", diff --git a/extensions/copilot/script/compareStestAlternativeRuns.ts b/extensions/copilot/script/compareStestAlternativeRuns.ts index 0ef10cc5160f9..4239afe27b3ad 100644 --- a/extensions/copilot/script/compareStestAlternativeRuns.ts +++ b/extensions/copilot/script/compareStestAlternativeRuns.ts @@ -2,6 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +/* eslint-disable local/code-no-dangerous-type-assertions */ import { AssertionError } from 'assert'; import { execFile } from 'child_process'; diff --git a/extensions/copilot/script/setup/getEnv.mts b/extensions/copilot/script/setup/getEnv.mts index 32c6c56cc8973..1044e74b73f17 100644 --- a/extensions/copilot/script/setup/getEnv.mts +++ b/extensions/copilot/script/setup/getEnv.mts @@ -20,22 +20,22 @@ async function setupSecretClient(vaultUri: string) { } // Always add the Azure CLI as an option - credentialOptions.push(new AzureCliCredential({ tenantId: "72f988bf-86f1-41af-91ab-2d7cd011db47" })); + credentialOptions.push(new AzureCliCredential({ tenantId: '72f988bf-86f1-41af-91ab-2d7cd011db47' })); // Check if terminal is interactive, non-interactive environments can't use // InteractiveBrowserCredential and don't necessarily have access to a keychain // For SSH sessions into Azure VMs, keychain is not available, requires managed identity if (process.stdin.isTTY && !process.env.AZURE_CLIENT_ID && !process.env.CODESPACES) { - credentialOptions.push(new InteractiveBrowserCredential({ tenantId: "72f988bf-86f1-41af-91ab-2d7cd011db47" })); + credentialOptions.push(new InteractiveBrowserCredential({ tenantId: '72f988bf-86f1-41af-91ab-2d7cd011db47' })); } // Use DeviceCodeCredential in Codespaces if (process.env.CODESPACES) { const deviceCodeCredential = new DeviceCodeCredential({ - tenantId: "72f988bf-86f1-41af-91ab-2d7cd011db47", + tenantId: '72f988bf-86f1-41af-91ab-2d7cd011db47', userPromptCallback: (info) => { - console.log("To authenticate, visit:", info.verificationUri); - console.log("Enter the code:", info.userCode); + console.log('To authenticate, visit:', info.verificationUri); + console.log('Enter the code:', info.userCode); } }); credentialOptions.push(deviceCodeCredential); @@ -51,20 +51,20 @@ async function fetchSecret(secretClient: SecretClient, secretName: string): Prom } async function fetchSecrets(): Promise<{ [key: string]: string | undefined }> { - const keyVaultClient = await setupSecretClient("https://copilot-automation.vault.azure.net/"); + const keyVaultClient = await setupSecretClient('https://copilot-automation.vault.azure.net/'); const secrets: { [key: string]: string | undefined } = {}; - secrets["HMAC_SECRET"] = await fetchSecret(keyVaultClient, "hmac-secret"); + secrets['HMAC_SECRET'] = await fetchSecret(keyVaultClient, 'hmac-secret'); if (!process.stdin.isTTY) { // only in automation - secrets["GITHUB_OAUTH_TOKEN"] = await fetchSecret(keyVaultClient, "capi-oauth"); - secrets["VSCODE_COPILOT_CHAT_TOKEN"] = await fetchSecret(keyVaultClient, "copilot-token"); - secrets["BLACKBIRD_EMBEDDINGS_KEY"] = await fetchSecret(keyVaultClient, "vsc-aoai-key"); - secrets["BLACKBIRD_REDIS_CACHE_KEY"] = await fetchSecret(keyVaultClient, "blackbird-redis-cache-key"); + secrets['GITHUB_OAUTH_TOKEN'] = await fetchSecret(keyVaultClient, 'capi-oauth'); + secrets['VSCODE_COPILOT_CHAT_TOKEN'] = await fetchSecret(keyVaultClient, 'copilot-token'); + secrets['BLACKBIRD_EMBEDDINGS_KEY'] = await fetchSecret(keyVaultClient, 'vsc-aoai-key'); + secrets['BLACKBIRD_REDIS_CACHE_KEY'] = await fetchSecret(keyVaultClient, 'blackbird-redis-cache-key'); try { - secrets["ANTHROPIC_API_KEY"] = await fetchSecret(keyVaultClient, "anthropic-key"); - secrets["DEEPSEEK_API_KEY"] = await fetchSecret(keyVaultClient, "deepseek-key"); + secrets['ANTHROPIC_API_KEY'] = await fetchSecret(keyVaultClient, 'anthropic-key'); + secrets['DEEPSEEK_API_KEY'] = await fetchSecret(keyVaultClient, 'deepseek-key'); } catch (error) { console.log(red(`Failed to fetch optional evaluation tokens. Skipping...`)); } diff --git a/extensions/copilot/script/setup/getToken.mts b/extensions/copilot/script/setup/getToken.mts index 1eca497f8c0e9..6b9d82c24499e 100644 --- a/extensions/copilot/script/setup/getToken.mts +++ b/extensions/copilot/script/setup/getToken.mts @@ -46,6 +46,7 @@ async function main(): Promise { }, }; const request1 = await fetch(REQUEST1_URL, requestOptions); + // eslint-disable-next-line local/code-no-any-casts const response1 = (await request1.json()) as any; console.log(`Copy this code: ${response1.user_code}`); console.log('Then press any key to launch the authorization page, paste the code in and approve the access.'); @@ -69,6 +70,7 @@ async function main(): Promise { 'Content-Type': 'application/json', }, }; + // eslint-disable-next-line local/code-no-any-casts const response2 = (await (await fetch(REQUEST2_URL, requestOptions)).json()) as any; expiresIn -= response1.interval; await new Promise(resolve => setTimeout(resolve, 1000 * response1.interval)); diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts index 48f78cdc1bed5..3328eae748db4 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts @@ -119,25 +119,22 @@ interface QueuedRequest { readonly token: vscode.CancellationToken; readonly yieldRequested?: () => boolean; readonly deferred: DeferredPromise; -} - -/** - * Represents the currently active request being processed - */ -interface CurrentRequest { - readonly stream: vscode.ChatResponseStream; - readonly toolInvocationToken: vscode.ChatParticipantToolToken; - readonly token: vscode.CancellationToken; - readonly yieldRequested?: () => boolean; + readonly modelId: ParsedClaudeModelId; + readonly permissionMode: PermissionMode; + readonly effort: EffortLevel | undefined; + readonly toolsSnapshot: ReadonlySet; } export class ClaudeCodeSession extends Disposable { private static readonly GATEWAY_IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes private _queryGenerator: Query | undefined; - private _promptQueue: QueuedRequest[] = []; - private _currentRequest: CurrentRequest | undefined; - private _pendingPrompt: DeferredPromise | undefined; + /** The deferred promise that should be resolved when the session should wake up and consume from the queued requests. */ + private _pendingPrompt: DeferredPromise | undefined; + /** Requests waiting to be sent to the SDK. */ + private _queuedRequests: QueuedRequest[] = []; + /** Requests that have been sent to the SDK and are awaiting completion; index 0 is the request currently being processed. */ + private _inFlightRequests: QueuedRequest[] = []; private _abortController = new AbortController(); private _editTracker: ExternalEditTracker; private _settingsChangeTracker: ClaudeSettingsChangeTracker; @@ -145,12 +142,16 @@ export class ClaudeCodeSession extends Disposable { private _currentPermissionMode: PermissionMode = 'acceptEdits'; private _currentEffort: EffortLevel | undefined; private _isResumed: boolean; - private _yieldInProgress = false; + private _pendingRestart = false; private _sessionStarting: Promise | undefined; private _currentToolNames: ReadonlySet | undefined; private _gateway: vscode.McpGateway | undefined; private _gatewayIdleTimeout: ReturnType | undefined; - private _otelTracker: ClaudeOTelTracker | undefined; + private _otelTracker: ClaudeOTelTracker; + + private get _currentRequest(): QueuedRequest | undefined { + return this._inFlightRequests[0]; + } /** * Sets the model on the active SDK session, or stores it for the next session start. @@ -271,8 +272,18 @@ export class ClaudeCodeSession extends Disposable { this._cancelGatewayIdleTimer(); this._disposeGateway(); this._abortController.abort(); - this._promptQueue.forEach(req => req.deferred.error(new Error('Session disposed'))); - this._promptQueue = []; + this._inFlightRequests.forEach(req => { + if (!req.deferred.isSettled) { + req.deferred.error(new Error('Session disposed')); + } + }); + this._inFlightRequests = []; + this._queuedRequests.forEach(req => { + if (!req.deferred.isSettled) { + req.deferred.error(new Error('Session disposed')); + } + }); + this._queuedRequests = []; this._pendingPrompt?.error(new Error('Session disposed')); this._pendingPrompt = undefined; super.dispose(); @@ -297,59 +308,40 @@ export class ClaudeCodeSession extends Disposable { this._cancelGatewayIdleTimer(); - // Check if settings files have changed since session started - if (this._queryGenerator && await this._settingsChangeTracker.hasChanges()) { - this.logService.trace('[ClaudeCodeSession] Settings files changed, restarting session with resume'); - this._restartSession(); - } - - // Check if the set of enabled tools has changed since the last request - if (this._queryGenerator && this._hasToolsChanged(request.tools)) { - this.logService.trace('[ClaudeCodeSession] Tools changed, restarting session with resume'); - this._restartSession(); - } - this._snapshotTools(request.tools); - - // Read current model and permission mode from session state service - // Do this BEFORE starting a session so the Options are correct from the start + // Snapshot per-request metadata from session state const modelId = this.sessionStateService.getModelIdForSession(this.sessionId); - const permissionMode = this.sessionStateService.getPermissionModeForSession(this.sessionId); - const effortLevel = this.sessionStateService.getReasoningEffortForSession(this.sessionId); - - if (effortLevel !== this._currentEffort) { - this._currentEffort = effortLevel; - // Effort doesn't have a direct setter on the query generator, so we need to restart the session - if (this._queryGenerator) { - this._restartSession(); - } - } - // Update model and permission mode on active session if they changed - if (modelId) { - await this._setModel(modelId); - } - await this._setPermissionMode(permissionMode); - - if (!this._queryGenerator) { - await this._startSession(token); + if (!modelId) { + throw new Error(`Model not set for session ${this.sessionId}. State must be committed before invoking.`); } + const permissionMode = this.sessionStateService.getPermissionModeForSession(this.sessionId); + const effort = this.sessionStateService.getReasoningEffortForSession(this.sessionId); + const toolsSnapshot = this._computeToolsSnapshot(request.tools); - // Add this request to the queue and wait for completion + // Add this request to the queue with its metadata snapshot const deferred = new DeferredPromise(); const queuedRequest: QueuedRequest = { request, stream, token, yieldRequested, - deferred + deferred, + modelId, + permissionMode, + effort, + toolsSnapshot, }; - this._promptQueue.push(queuedRequest); + this._queuedRequests.push(queuedRequest); + + if (!this._queryGenerator) { + await this._startSession(token); + } - // If there's a pending prompt request, fulfill it immediately + // Wake up the iterable if it's awaiting the next request. if (this._pendingPrompt) { const pendingPrompt = this._pendingPrompt; this._pendingPrompt = undefined; - pendingPrompt.complete(queuedRequest); + pendingPrompt.complete(); } return deferred.p; @@ -380,10 +372,17 @@ export class ClaudeCodeSession extends Disposable { if (!folderInfo) { throw new Error(`No folder info found for session ${this.sessionId}. State must be committed before invoking.`); } - const currentModelId = this._currentModelId; - if (!currentModelId) { - throw new Error(`Model not set for session ${this.sessionId}. State must be committed before invoking.`); + const headRequest = this._queuedRequests[0]; + if (!headRequest) { + throw new Error(`No queued request to start session ${this.sessionId} with.`); } + + // Seed session state from the head request's metadata + this._currentModelId = headRequest.modelId; + this._currentPermissionMode = headRequest.permissionMode; + this._currentEffort = headRequest.effort; + this._currentToolNames = headRequest.toolsSnapshot; + const { cwd, additionalDirectories } = folderInfo; // Build options for the Claude Code SDK @@ -423,6 +422,9 @@ export class ClaudeCodeSession extends Disposable { this.logService.warn(`[ClaudeCodeSession] Failed to resolve skill locations for plugins: ${errorMessage}`); } + // Take a snapshot of settings files so we can detect changes + await this._settingsChangeTracker.takeSnapshot(); + const serverConfig = this.langModelServer.getConfig(); const options: Options = { cwd, @@ -431,7 +433,7 @@ export class ClaudeCodeSession extends Disposable { // the permission mode ourselves in the options allowDangerouslySkipPermissions: true, abortController: this._abortController, - effort: this._currentEffort, + effort: headRequest.effort, executable: process.execPath as 'node', // get it to fork the EH node process // TODO: CAPI does not yet support the WebSearch tool // Once it does, we can re-enable it. @@ -441,9 +443,9 @@ export class ClaudeCodeSession extends Disposable { ? { resume: this.sessionId } : { sessionId: this.sessionId }), // Pass the model selection to the SDK - model: currentModelId.toSdkModelId(), + model: headRequest.modelId.toSdkModelId(), // Pass the permission mode to the SDK - permissionMode: this._currentPermissionMode, + permissionMode: headRequest.permissionMode, includeHookEvents: true, mcpServers, plugins, @@ -468,7 +470,7 @@ export class ClaudeCodeSession extends Disposable { } this.logService.trace(`[ClaudeCodeSession]: canUseTool: ${name}(${JSON.stringify(input)})`); return this.toolPermissionService.canUseTool(name, input, { - toolInvocationToken: this._currentRequest.toolInvocationToken, + toolInvocationToken: this._currentRequest.request.toolInvocationToken, permissionMode: this._currentPermissionMode, stream: this._currentRequest.stream }); @@ -491,8 +493,6 @@ export class ClaudeCodeSession extends Disposable { // Fire-and-forget to avoid blocking session startup — error handling is inside the service. void this.runtimeDataService.update(this._queryGenerator); - // Take a snapshot of settings files so we can detect changes - await this._settingsChangeTracker.takeSnapshot(); // Start the message processing loop (fire-and-forget, but _processMessages // handles all errors internally via try/catch → _cleanup) @@ -504,23 +504,39 @@ export class ClaudeCodeSession extends Disposable { private async *_createPromptIterable(): AsyncIterable { while (true) { // Wait for a request to be available - const request = await this._getNextRequest(); - - this._currentRequest = { - stream: request.stream, - toolInvocationToken: request.request.toolInvocationToken, - token: request.token, - yieldRequested: request.yieldRequested - }; + while (this._queuedRequests.length === 0) { + this._pendingPrompt = new DeferredPromise(); + await this._pendingPrompt.p; + } + const request = this._queuedRequests.shift()!; + + // Check settings file changes when no other request is in flight + if (this._inFlightRequests.length === 0 && await this._settingsChangeTracker.hasChanges()) { + this.logService.trace('[ClaudeCodeSession] Settings files changed, restarting session with resume'); + this._queuedRequests.unshift(request); + this._pendingRestart = true; + this._isResumed = true; + return; + } - const currentModelId = this._currentModelId; - if (!currentModelId) { - throw new Error(`Model not set for session ${this.sessionId}`); + // Check non-hot-swappable changes that require a session restart + if (request.effort !== this._currentEffort || !this._toolsMatch(request.toolsSnapshot)) { + this._queuedRequests.unshift(request); + this._pendingRestart = true; + this._isResumed = true; + return; } + // Hot-swap model and permission mode on the active session + await this._setModel(request.modelId); + await this._setPermissionMode(request.permissionMode); + + // Mark this request as yielded to the SDK; it becomes the current request. + this._inFlightRequests.push(request); + // Increment user-initiated message count for this model // This is used by the language model server to track which requests are user-initiated - this.langModelServer.incrementUserInitiatedMessageCount(currentModelId.toEndpointModelId()); + this.langModelServer.incrementUserInitiatedMessageCount(request.modelId.toEndpointModelId()); // Resolve the prompt content blocks now that this request is being handled const prompt = await resolvePromptToContentBlocks(request.request); @@ -534,10 +550,10 @@ export class ClaudeCodeSession extends Disposable { ); // Start OTel tracking for this request - this._otelTracker!.startRequest(currentModelId.toEndpointModelId()); + this._otelTracker.startRequest(request.modelId.toEndpointModelId()); // Emit user_message span event for the debug panel - this._otelTracker!.emitUserMessage(promptLabel); + this._otelTracker.emitUserMessage(promptLabel); yield { type: 'user', @@ -545,32 +561,16 @@ export class ClaudeCodeSession extends Disposable { role: 'user', content: prompt }, + priority: 'now', parent_tool_use_id: null, session_id: this.sessionId, // NOTE: messageId seems to be in the format request_ but it doesn't seem // to be a problem to use as the message ID for the SDK. uuid: request.request.id as `${string}-${string}-${string}-${string}-${string}` }; - - // Wait for this request to complete before yielding the next one - await request.deferred.p; } } - /** - * Gets the next request from the queue or waits for one to be available - * @returns Promise that resolves with the next queued request - */ - private async _getNextRequest(): Promise { - if (this._promptQueue.length > 0) { - return this._promptQueue[0]; // Don't shift yet, keep for resolution - } - - // Wait for a request to be queued - this._pendingPrompt = new DeferredPromise(); - return this._pendingPrompt.p; - } - /** * Processes messages from the Claude Code query generator * Routes messages to appropriate handlers and manages request completion @@ -582,65 +582,116 @@ export class ClaudeCodeSession extends Disposable { try { const unprocessedToolCalls = new Map(); for await (const message of this._queryGenerator!) { - // Check if current request was cancelled - if (this._currentRequest?.token.isCancellationRequested) { - throw new Error('Request was cancelled'); - } - // Mark session as resumed after first SDK message confirms session exists on disk. // This ensures future restarts (yield, settings change) use `resume` instead of `sessionId`. if (message.session_id && !this._isResumed) { this._isResumed = true; } - // Check yield before processing to avoid streaming partial responses - if (await this._checkYieldRequested()) { - continue; - } - // Skip if no current request (e.g., after yield cleared it) if (!this._currentRequest) { this.logService.trace('[ClaudeCodeSession] Skipping message - no current request'); continue; } + const currentRequest = this._currentRequest; + + // Check if current request was cancelled + if (currentRequest.token.isCancellationRequested) { + throw new Error('Request was cancelled'); + } + // Track OTel metrics from SDK messages - this._otelTracker!.onMessage(message, subagentTraceContexts); + this._otelTracker.onMessage(message, subagentTraceContexts); this.logService.trace(`claude-agent-sdk Message: ${JSON.stringify(message, null, 2)}`); - const result = this.instantiationService.invokeFunction(dispatchMessage, message, this.sessionId, { - stream: this._currentRequest.stream, - toolInvocationToken: this._currentRequest.toolInvocationToken, - editTracker: this._editTracker, - token: this._currentRequest.token, - }, { - unprocessedToolCalls, - otelToolSpans, - otelHookSpans, - parentTraceContext: this._otelTracker!.traceContext, - subagentTraceContexts, - }); + let result; + try { + result = this.instantiationService.invokeFunction(dispatchMessage, message, this.sessionId, { + stream: currentRequest.stream, + toolInvocationToken: currentRequest.request.toolInvocationToken, + editTracker: this._editTracker, + token: currentRequest.token, + }, { + unprocessedToolCalls, + otelToolSpans, + otelHookSpans, + parentTraceContext: this._otelTracker.traceContext, + subagentTraceContexts, + }); + } catch (dispatchError) { + this.logService.warn(`[ClaudeCodeSession] Failed to dispatch message (stream may be disposed after yield): ${dispatchError}`); + } + + if (currentRequest.yieldRequested?.()) { + this.logService.trace('[ClaudeCodeSession] Yield requested - signaling session completion so next request can start'); + + // Complete the current request gracefully but don't kill the session + if (!currentRequest.deferred.isSettled) { + await currentRequest.deferred.complete(); + } + } if (result?.requestComplete) { // End the invoke_agent span for this request - this._otelTracker!.endRequest(); + this._otelTracker.endRequest(); // Clear the capturing token so subsequent requests get their own this.sessionStateService.setCapturingTokenForSession(this.sessionId, undefined); - // Resolve and remove the completed request - if (this._promptQueue.length > 0) { - const completedRequest = this._promptQueue.shift()!; - await completedRequest.deferred.complete(); + const completed = this._inFlightRequests.shift(); + if (completed && !completed.deferred.isSettled) { + await completed.deferred.complete(); + } + if (this._inFlightRequests.length === 0 && this._queuedRequests.length === 0) { + this._startGatewayIdleTimer(); } - this._currentRequest = undefined; - this._startGatewayIdleTimer(); subagentTraceContexts.clear(); } } // Generator ended normally - clean up so next invoke starts fresh - this._cleanup(new Error('Session ended unexpectedly')); + throw new Error('Session ended unexpectedly'); } catch (error) { - this._cleanup(error as Error); + // Graceful restart: the prompt iterable detected a non-hot-swappable change + // (effort or tools). Preserve queued requests and start a fresh session. + if (this._pendingRestart) { + this._pendingRestart = false; + this._restartSession(); + const headToken = this._queuedRequests[0]?.token; + if (headToken) { + await this._startSession(headToken); + } + return; + } + + // Clear the capturing token so it doesn't leak across sessions or error boundaries + this.sessionStateService.setCapturingTokenForSession(this.sessionId, undefined); + // End invoke_agent span with error if still open + this._otelTracker.endRequestWithError(error.message); + + // Resets session state so the next session start can begin fresh. + // Preserves the sessionId for SDK resume. + + this._queryGenerator = undefined; + this._abortController = new AbortController(); + + // Rejects all pending requests and clears the queues. + + this._inFlightRequests.forEach(req => { + if (!req.deferred.isSettled) { + req.deferred.error(error); + } + }); + this._inFlightRequests = []; + this._queuedRequests.forEach(req => { + if (!req.deferred.isSettled) { + req.deferred.error(error); + } + }); + this._queuedRequests = []; + if (this._pendingPrompt && !this._pendingPrompt.isSettled) { + this._pendingPrompt.error(error); + } + this._pendingPrompt = undefined; } finally { // Clean up any remaining OTel spans for (const [, span] of otelToolSpans) { @@ -654,108 +705,20 @@ export class ClaudeCodeSession extends Disposable { } otelHookSpans.clear(); // End any lingering invoke_agent span - this._otelTracker!.endRequestWithError('session ended'); - } - } - - private _cleanup(error: Error): void { - // Clear the capturing token so it doesn't leak across sessions or error boundaries - this.sessionStateService.setCapturingTokenForSession(this.sessionId, undefined); - // End invoke_agent span with error if still open - this._otelTracker!.endRequestWithError(error.message); - this._resetSessionState(); - - const wasYielding = this._yieldInProgress; - this._yieldInProgress = false; - - if (wasYielding) { - this._restartAfterYield(); - } else { - this._rejectPendingRequests(error); - } - } - - /** - * Resets session state so the next session start can begin fresh. - * Preserves the sessionId for SDK resume. - */ - private _resetSessionState(): void { - this._queryGenerator = undefined; - this._abortController = new AbortController(); - this._currentRequest = undefined; - this._currentEffort = undefined; - } - - /** - * After a yield, preserves the queue and restarts the session to process - * any pending requests (e.g., the steering message). - */ - private _restartAfterYield(): void { - this.logService.trace(`[ClaudeCodeSession] Yield cleanup, sessionId=${this.sessionId}, pending requests=${this._promptQueue.length}`); - - if (this._promptQueue.length > 0) { - const nextRequest = this._promptQueue[0]; - void this._startSession(nextRequest.token).catch(err => { - this.logService.error('[ClaudeCodeSession] Failed to restart session after yield', err); - this._rejectPendingRequests(err); - }); - } - } - - /** - * Rejects all pending requests and clears the queue. - */ - private _rejectPendingRequests(error: Error): void { - this._promptQueue.forEach(req => { - if (!req.deferred.isSettled) { - req.deferred.error(error); - } - }); - this._promptQueue = []; - if (this._pendingPrompt && !this._pendingPrompt.isSettled) { - this._pendingPrompt.error(error); + this._otelTracker.endRequestWithError('session ended'); } - this._pendingPrompt = undefined; } /** - * Checks if the user has requested to interrupt the current request. - * If so, completes the current request gracefully and aborts the SDK to allow the next message. - * @returns true if a yield was detected and handled, false otherwise - */ - private async _checkYieldRequested(): Promise { - if (!this._currentRequest?.yieldRequested?.()) { - return false; - } - - this.logService.trace('[ClaudeCodeSession] Yield requested - interrupting session to allow user interruption'); - this._yieldInProgress = true; - - // Complete the current request gracefully - if (this._promptQueue.length > 0) { - const completedRequest = this._promptQueue.shift()!; - await completedRequest.deferred.complete(); - } - this._currentRequest = undefined; - - // Signal the SDK to stop generating - this._abortController.abort(); - - return true; - } - - /** - * Restarts the session to pick up settings changes. - * Clears the query generator but preserves the session ID for resume. + * Restarts the session by aborting the current SDK connection. + * The abort causes _processMessages to enter error cleanup, which + * rejects any remaining requests and resets session state. */ private _restartSession(): void { - // Clear the generator so _startSession will be called with resume this._queryGenerator = undefined; this._abortController.abort(); this._abortController = new AbortController(); this._isResumed = true; - // Note: We don't clear the prompt queue or pending prompts here - // because we're not erroring out, just restarting for settings reload } // #region Gateway Lifecycle @@ -784,11 +747,11 @@ export class ClaudeCodeSession extends Disposable { // #endregion /** - * Takes a snapshot of the current tools for later comparison. + * Computes a snapshot of the MCP tool names from a chat request's tools map. */ - private _snapshotTools(tools: vscode.ChatRequest['tools']): void { + private _computeToolsSnapshot(tools: vscode.ChatRequest['tools']): ReadonlySet { // TODO: Handle the enabled/disabled (true/false) state per tool once we have UI for it - this._currentToolNames = new Set( + return new Set( [...tools] .filter(([tool]) => tool.source instanceof LanguageModelToolMCPSource) .map(([tool]) => tool.name) @@ -796,31 +759,24 @@ export class ClaudeCodeSession extends Disposable { } /** - * Checks whether the set of enabled tools has changed since the last snapshot. + * Checks whether a tools snapshot matches the current session's tools. */ - private _hasToolsChanged(tools: vscode.ChatRequest['tools']): boolean { + private _toolsMatch(snapshot: ReadonlySet): boolean { if (!this._currentToolNames) { - return false; + return true; } - // TODO: Handle the enabled/disabled (true/false) state per tool once we have UI for it - const newToolNames = new Set( - [...tools] - .filter(([tool]) => tool.source instanceof LanguageModelToolMCPSource) - .map(([tool]) => tool.name) - ); - - if (newToolNames.size !== this._currentToolNames.size) { - return true; + if (snapshot.size !== this._currentToolNames.size) { + return false; } - for (const name of newToolNames) { + for (const name of snapshot) { if (!this._currentToolNames.has(name)) { - return true; + return false; } } - return false; + return true; } } diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeCodeAgent.spec.ts b/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeCodeAgent.spec.ts index 8e6d568d0c088..926e36325ce5e 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeCodeAgent.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeCodeAgent.spec.ts @@ -12,6 +12,9 @@ import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle'; import { URI } from '../../../../../util/vs/base/common/uri'; import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation'; import { ChatReferenceBinaryData } from '../../../../../vscodeTypes'; +import { LanguageModelToolMCPSource } from '../../../../../util/common/test/shims/chatTypes'; +import { IFileSystemService } from '../../../../../platform/filesystem/common/fileSystemService'; +import type { MockFileSystemService } from '../../../../../platform/filesystem/node/test/mockFileSystemService'; import { createExtensionUnitTestingServices } from '../../../../test/node/services'; import { MockChatResponseStream, TestChatRequest } from '../../../../test/node/testHelpers'; import type { ClaudeFolderInfo } from '../../common/claudeFolderInfo'; @@ -573,3 +576,291 @@ describe('ClaudeAgentManager - error handling', () => { expect(result.errorDetails).toBeDefined(); }); }); + +describe('ClaudeCodeSession - yield flow', () => { + const store = new DisposableStore(); + let instantiationService: IInstantiationService; + let sessionStateService: IClaudeSessionStateService; + let mockService: MockClaudeCodeSdkService; + + beforeEach(() => { + const services = store.add(createExtensionUnitTestingServices()); + const accessor = services.createTestingAccessor(); + instantiationService = accessor.get(IInstantiationService); + sessionStateService = accessor.get(IClaudeSessionStateService); + mockService = accessor.get(IClaudeCodeSdkService) as MockClaudeCodeSdkService; + mockService.queryCallCount = 0; + }); + + afterEach(() => { + store.clear(); + vi.resetAllMocks(); + }); + + it('yield completes the current request while session continues', async () => { + const mockServer = createMockLangModelServer(); + commitTestState(sessionStateService, 'test-session'); + const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true)); + + const stream1 = new MockChatResponseStream(); + // yieldRequested is set before _processMessages runs (async session start), + // so the yield check triggers on the first dispatched message + const promise1 = session.invoke(createMockChatRequest('First'), stream1, () => true, CancellationToken.None); + await promise1; + + // Session should still be alive — send a second request + const stream2 = new MockChatResponseStream(); + const promise2 = session.invoke(createMockChatRequest('Second'), stream2, undefined, CancellationToken.None); + await promise2; + + expect(stream2.output.join('\n')).toContain('Hello from mock!'); + expect(mockService.queryCallCount).toBe(1); + }); + + it('second request after yield uses priority now', async () => { + const mockServer = createMockLangModelServer(); + commitTestState(sessionStateService, 'test-session'); + const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true)); + + const stream1 = new MockChatResponseStream(); + await session.invoke(createMockChatRequest('First'), stream1, () => true, CancellationToken.None); + + const stream2 = new MockChatResponseStream(); + await session.invoke(createMockChatRequest('Second'), stream2, undefined, CancellationToken.None); + + // The second message yielded to the SDK should have priority 'now' + expect(mockService.receivedMessages.length).toBeGreaterThanOrEqual(2); + expect(mockService.receivedMessages[1].priority).toBe('now'); + }); + + it('multiple yield cycles work correctly', async () => { + const mockServer = createMockLangModelServer(); + commitTestState(sessionStateService, 'test-session'); + const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true)); + + // A → yield → B → yield → C + const streamA = new MockChatResponseStream(); + await session.invoke(createMockChatRequest('A'), streamA, () => true, CancellationToken.None); + + const streamB = new MockChatResponseStream(); + await session.invoke(createMockChatRequest('B'), streamB, () => true, CancellationToken.None); + + const streamC = new MockChatResponseStream(); + await session.invoke(createMockChatRequest('C'), streamC, undefined, CancellationToken.None); + + expect(streamC.output.join('\n')).toContain('Hello from mock!'); + expect(mockService.queryCallCount).toBe(1); + expect(mockService.receivedMessages).toHaveLength(3); + }); +}); + +describe('ClaudeCodeSession - settings change restart', () => { + const store = new DisposableStore(); + let instantiationService: IInstantiationService; + let sessionStateService: IClaudeSessionStateService; + let mockService: MockClaudeCodeSdkService; + let mockFs: MockFileSystemService; + + beforeEach(() => { + const services = store.add(createExtensionUnitTestingServices()); + const accessor = services.createTestingAccessor(); + instantiationService = accessor.get(IInstantiationService); + sessionStateService = accessor.get(IClaudeSessionStateService); + mockService = accessor.get(IClaudeCodeSdkService) as MockClaudeCodeSdkService; + mockService.queryCallCount = 0; + mockFs = accessor.get(IFileSystemService) as MockFileSystemService; + }); + + afterEach(() => { + store.clear(); + vi.resetAllMocks(); + }); + + it('restarts session when settings files change between requests', async () => { + const mockServer = createMockLangModelServer(); + commitTestState(sessionStateService, 'test-session'); + const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true)); + + // First request establishes the session and takes a settings snapshot + const stream1 = new MockChatResponseStream(); + await session.invoke(createMockChatRequest('Hello'), stream1, undefined, CancellationToken.None); + expect(mockService.queryCallCount).toBe(1); + + // Simulate a CLAUDE.md file being created (settings change) + const claudeMdUri = URI.joinPath(URI.file('/home/testuser'), '.claude', 'CLAUDE.md'); + mockFs.mockFile(claudeMdUri, '# Instructions', 2000); + + // Second request should trigger settings change → restart (new query created) + const stream2 = new MockChatResponseStream(); + await session.invoke(createMockChatRequest('Hello again'), stream2, undefined, CancellationToken.None); + expect(mockService.queryCallCount).toBe(2); + }); + + it('uses resume after settings change restart', async () => { + const mockServer = createMockLangModelServer(); + commitTestState(sessionStateService, 'test-session'); + const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true)); + + // First request — new session + const stream1 = new MockChatResponseStream(); + await session.invoke(createMockChatRequest('Hello'), stream1, undefined, CancellationToken.None); + expect(mockService.lastQueryOptions?.sessionId).toBe('test-session'); + + // Trigger settings change + const claudeMdUri = URI.joinPath(URI.file('/home/testuser'), '.claude', 'CLAUDE.md'); + mockFs.mockFile(claudeMdUri, '# Instructions', 2000); + + // Second request — should use resume, not sessionId + const stream2 = new MockChatResponseStream(); + await session.invoke(createMockChatRequest('Hello again'), stream2, undefined, CancellationToken.None); + expect(mockService.lastQueryOptions?.resume).toBe('test-session'); + expect(mockService.lastQueryOptions?.sessionId).toBeUndefined(); + }); + + it('does not restart when settings files have not changed', async () => { + const mockServer = createMockLangModelServer(); + commitTestState(sessionStateService, 'test-session'); + const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true)); + + const stream1 = new MockChatResponseStream(); + await session.invoke(createMockChatRequest('Hello'), stream1, undefined, CancellationToken.None); + expect(mockService.queryCallCount).toBe(1); + + // No file changes — session should be reused + const stream2 = new MockChatResponseStream(); + await session.invoke(createMockChatRequest('Hello again'), stream2, undefined, CancellationToken.None); + expect(mockService.queryCallCount).toBe(1); + }); +}); + +describe('ClaudeCodeSession - effort and tools restart', () => { + const store = new DisposableStore(); + let instantiationService: IInstantiationService; + let sessionStateService: IClaudeSessionStateService; + let mockService: MockClaudeCodeSdkService; + + beforeEach(() => { + const services = store.add(createExtensionUnitTestingServices()); + const accessor = services.createTestingAccessor(); + instantiationService = accessor.get(IInstantiationService); + sessionStateService = accessor.get(IClaudeSessionStateService); + mockService = accessor.get(IClaudeCodeSdkService) as MockClaudeCodeSdkService; + mockService.queryCallCount = 0; + }); + + afterEach(() => { + store.clear(); + vi.resetAllMocks(); + }); + + it('uses resume after effort change restart', async () => { + const mockServer = createMockLangModelServer(); + commitTestState(sessionStateService, 'test-session'); + const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true)); + + // First request — new session + const stream1 = new MockChatResponseStream(); + await session.invoke(createMockChatRequest('Hello'), stream1, undefined, CancellationToken.None); + expect(mockService.lastQueryOptions?.sessionId).toBe('test-session'); + + // Change effort + sessionStateService.setReasoningEffortForSession('test-session', 'high'); + + // Restarted session should use resume + const stream2 = new MockChatResponseStream(); + await session.invoke(createMockChatRequest('Hello again'), stream2, undefined, CancellationToken.None); + expect(mockService.lastQueryOptions?.resume).toBe('test-session'); + expect(mockService.lastQueryOptions?.effort).toBe('high'); + }); + + it('restarts session when MCP tools change', async () => { + const mockServer = createMockLangModelServer(); + commitTestState(sessionStateService, 'test-session'); + const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true)); + + // First request with no MCP tools + const stream1 = new MockChatResponseStream(); + await session.invoke(createMockChatRequest('Hello'), stream1, undefined, CancellationToken.None); + expect(mockService.queryCallCount).toBe(1); + + // Second request with a new MCP tool + const stream2 = new MockChatResponseStream(); + const mcpTool = { name: 'mcp-tool', source: new LanguageModelToolMCPSource('test-server', 'test-server', undefined) } as unknown as vscode.LanguageModelChatTool; + const reqWithTool: vscode.ChatRequest = { + prompt: 'Hello again', + references: [], + tools: new Map([[mcpTool, true]]), + id: 'test-request-2', + toolInvocationToken: {} + } as unknown as vscode.ChatRequest; + await session.invoke(reqWithTool, stream2, undefined, CancellationToken.None); + expect(mockService.queryCallCount).toBe(2); + }); + + it('does not restart when MCP tools are unchanged', async () => { + const mockServer = createMockLangModelServer(); + commitTestState(sessionStateService, 'test-session'); + const session = store.add(instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true)); + + const mcpTool = { name: 'mcp-tool', source: new LanguageModelToolMCPSource('test-server', 'test-server', undefined) } as unknown as vscode.LanguageModelChatTool; + const makeReq = () => ({ + prompt: 'Hello', + references: [], + tools: new Map([[mcpTool, true]]), + id: 'test-request', + toolInvocationToken: {} + } as unknown as vscode.ChatRequest); + + const stream1 = new MockChatResponseStream(); + await session.invoke(makeReq(), stream1, undefined, CancellationToken.None); + expect(mockService.queryCallCount).toBe(1); + + const stream2 = new MockChatResponseStream(); + await session.invoke(makeReq(), stream2, undefined, CancellationToken.None); + expect(mockService.queryCallCount).toBe(1); + }); +}); + +describe('ClaudeCodeSession - edge cases', () => { + const store = new DisposableStore(); + let instantiationService: IInstantiationService; + let sessionStateService: IClaudeSessionStateService; + + beforeEach(() => { + const services = store.add(createExtensionUnitTestingServices()); + const accessor = services.createTestingAccessor(); + instantiationService = accessor.get(IInstantiationService); + sessionStateService = accessor.get(IClaudeSessionStateService); + }); + + afterEach(() => { + store.clear(); + vi.resetAllMocks(); + }); + + it('rejects in-flight requests when disposed', async () => { + const mockServer = createMockLangModelServer(); + commitTestState(sessionStateService, 'test-session'); + const session = instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true); + + const stream = new MockChatResponseStream(); + const promise = session.invoke(createMockChatRequest('Hello'), stream, undefined, CancellationToken.None); + + // Dispose immediately — the in-flight request should be rejected + session.dispose(); + + await expect(promise).rejects.toThrow(); + }); + + it('rejects new requests after dispose', async () => { + const mockServer = createMockLangModelServer(); + commitTestState(sessionStateService, 'test-session'); + const session = instantiationService.createInstance(ClaudeCodeSession, mockServer, 'test-session', true); + session.dispose(); + + const stream = new MockChatResponseStream(); + await expect( + session.invoke(createMockChatRequest('Hello'), stream, undefined, CancellationToken.None) + ).rejects.toThrow('Session disposed'); + }); +}); diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts index de731aed2a0b5..036c8cb49f8f6 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts @@ -260,7 +260,7 @@ export type ISessionOptions = { mcpServerMappings?: McpServerMappings; additionalWorkspaces?: IWorkspaceInfo[]; sessionParentId?: string; -} +}; export type IGetSessionOptions = ISessionOptions & { sessionId: string }; export type ICreateSessionOptions = ISessionOptions & { sessionId?: string }; diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts index 95ff49d8534e5..5063a8a673971 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts @@ -88,11 +88,13 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod */ private async getAgentItems(_token: vscode.CancellationToken): Promise { const agentInfos = await this.copilotCLIAgents.getAgents(); - return agentInfos.map(({ agent, sourceUri }) => ({ + return agentInfos.map(({ agent, sourceUri, pluginUri, extensionId }) => ({ uri: sourceUri, type: vscode.ChatSessionCustomizationType.Agent, name: agent.displayName || agent.name, description: agent.description, + extensionId, + pluginUri })); } @@ -133,7 +135,10 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod uri, type: vscode.ChatSessionCustomizationType.Instructions, name: basename(uri), + description: undefined, groupKey: 'agent-instructions', + extensionId: undefined, + pluginUri: undefined }); } @@ -166,6 +171,8 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod groupKey: 'context-instructions', badge, badgeTooltip, + extensionId: instruction.extensionId, + pluginUri: instruction.pluginUri }); } else { items.push({ @@ -174,6 +181,8 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod name, description, groupKey: 'on-demand-instructions', + extensionId: instruction.extensionId, + pluginUri: instruction.pluginUri }); } } @@ -189,6 +198,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod uri: s.uri, type: vscode.ChatSessionCustomizationType.Skill, name: s.name, + description: s.description, extensionId: s.extensionId, pluginUri: s.pluginUri, })); @@ -203,6 +213,9 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod uri: h.uri, type: vscode.ChatSessionCustomizationType.Hook, name: basename(h.uri).replace(/\.json$/i, ''), + description: undefined, + extensionId: h.extensionId, + pluginUri: h.pluginUri, })); } @@ -214,6 +227,9 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod uri: p.uri, type: vscode.ChatSessionCustomizationType.Plugins, name: basename(p.uri), + description: undefined, + extensionId: undefined, + pluginUri: undefined, })); } } diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLICustomizationProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLICustomizationProvider.spec.ts index a9f947bdfde35..e2407ec251874 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLICustomizationProvider.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLICustomizationProvider.spec.ts @@ -65,7 +65,7 @@ function makeSkill(uri: URI, name: string): vscode.ChatSkill { /** Creates a ChatHook stub. */ function makeHook(uri: URI): vscode.ChatHook { - return { uri }; + return { uri, source: 'local' }; } /** Creates a ChatPlugin stub. */ diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts index 89f766fd1a620..85dfb099b4d26 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts @@ -108,6 +108,8 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch type: vscode.ChatSessionCustomizationType.Agent, name: agent.name, description: agent.description, + extensionId: undefined, + pluginUri: undefined, // No groupKey — vscode infers Built-in from non-file: scheme }); } @@ -121,6 +123,9 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch uri: agent.uri, type: vscode.ChatSessionCustomizationType.Agent, name, + description: agent.description, + extensionId: agent.extensionId, + pluginUri: agent.pluginUri, }); } } @@ -142,6 +147,9 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch uri: skill.uri, type: vscode.ChatSessionCustomizationType.Skill, name: skill.name, + description: skill.description, + extensionId: skill.extensionId, + pluginUri: skill.pluginUri, }; skillItems.push(item); } @@ -183,6 +191,9 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch uri, type: vscode.ChatSessionCustomizationType.Instructions, name, + description: undefined, + extensionId: undefined, + pluginUri: undefined, }); } } @@ -225,6 +236,8 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch type: vscode.ChatSessionCustomizationType.Hook, name: `${eventId}${matcherLabel}`, description: hook.command, + extensionId: undefined, + pluginUri: undefined, }); } } diff --git a/extensions/copilot/src/extension/common/modelContextProtocol.ts b/extensions/copilot/src/extension/common/modelContextProtocol.ts index 4d05d046a4c8a..fc1d33efea7df 100644 --- a/extensions/copilot/src/extension/common/modelContextProtocol.ts +++ b/extensions/copilot/src/extension/common/modelContextProtocol.ts @@ -2,8 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ - -/* eslint-disable local/no-unexternalized-strings */ +/* eslint-disable local/code-no-unexternalized-strings */ //#region proposals /** diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/cuda-cpp.tmLanguage.ts b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/cuda-cpp.tmLanguage.ts index 4c7dc372c8418..42d89a81c22d5 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/cuda-cpp.tmLanguage.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/cuda-cpp.tmLanguage.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable local/no-unexternalized-strings */ +/* eslint-disable local/code-no-unexternalized-strings */ import { LanguageInput } from 'shiki/core'; // This file has been converted from https://github.com/NVIDIA/cuda-cpp-grammar/blob/master/syntaxes/cuda-cpp.tmLanguage.json diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/javaScriptReact.tmLanguage.ts b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/javaScriptReact.tmLanguage.ts index 1dfb59d1eb21e..26d6d0624311a 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/javaScriptReact.tmLanguage.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/javaScriptReact.tmLanguage.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable local/no-unexternalized-strings */ +/* eslint-disable local/code-no-unexternalized-strings */ import { LanguageInput } from 'shiki/core'; // This file has been converted from https://github.com/microsoft/TypeScript-TmLanguage/blob/master/TypeScriptReact.tmLanguage diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/markdown-latex-combined.tmLanguage.ts b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/markdown-latex-combined.tmLanguage.ts index dfa4c24e3cb37..de4f7508f8d94 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/markdown-latex-combined.tmLanguage.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/markdown-latex-combined.tmLanguage.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable local/no-unexternalized-strings */ +/* eslint-disable local/code-no-unexternalized-strings */ import { LanguageInput } from 'shiki/core'; // This file has been converted from https://github.com/jlelong/vscode-latex-basics/blob/master/syntaxes/markdown-latex-combined.tmLanguage.json diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/rst.tmLanguage.ts b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/rst.tmLanguage.ts index 999bb5ecad503..57235421a48a8 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/rst.tmLanguage.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/languages/rst.tmLanguage.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable local/no-unexternalized-strings */ +/* eslint-disable local/code-no-unexternalized-strings */ import { LanguageInput } from 'shiki/core'; // This file has been converted from https://github.com/trond-snekvik/vscode-rst/blob/master/syntaxes/rst.tmLanguage.json diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/test/telemetry.ts b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/test/telemetry.ts index b2eedee3c140a..a92ae657a1e03 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/test/telemetry.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/test/telemetry.ts @@ -116,6 +116,7 @@ export async function withInMemoryTelemetry( reporters.setReporter(reporter); reporters.setEnhancedReporter(enhancedReporter); const result = await work(accessor); + // eslint-disable-next-line local/code-no-accessor-after-await const queue = accessor.get(ICompletionsPromiseQueueService) as TestPromiseQueue; await queue.awaitPromises(); diff --git a/extensions/copilot/src/extension/inlineChat/node/promptCraftingTypes.ts b/extensions/copilot/src/extension/inlineChat/node/promptCraftingTypes.ts index 37d93b080fd85..c30c318ec9b91 100644 --- a/extensions/copilot/src/extension/inlineChat/node/promptCraftingTypes.ts +++ b/extensions/copilot/src/extension/inlineChat/node/promptCraftingTypes.ts @@ -6,13 +6,11 @@ import type * as vscode from 'vscode'; import { IEditSurvivalTrackingSession } from '../../../platform/editSurvivalTracking/common/editSurvivalTrackerService'; import { ChatResponseStreamImpl } from '../../../util/common/chatResponseStreamImpl'; -import { ILanguage } from '../../../util/common/languages'; import { ResourceSet } from '../../../util/vs/base/common/map'; import { ChatResponseMarkdownPart, ChatResponseNotebookEditPart, ChatResponseTextEditPart } from '../../../vscodeTypes'; import { ChatTelemetry } from '../../prompt/node/chatParticipantTelemetry'; import { IDocumentContext } from '../../prompt/node/documentContext'; import { IIntent } from '../../prompt/node/intents'; -import { CodeContextRegion } from './codeContextRegion'; //#region interpreting copilot response @@ -133,13 +131,6 @@ export interface PromptQuery extends IDocumentContext { intent?: IIntent; } -export interface ICodeContextInfo { - language: ILanguage; - above: CodeContextRegion; - range: CodeContextRegion; - below: CodeContextRegion; -} - export class CopilotInteractiveEditorResponse { constructor( readonly store: ISessionTurnStorage | undefined, diff --git a/extensions/copilot/src/extension/inlineChat2/node/inlineChatIntent.ts b/extensions/copilot/src/extension/inlineChat2/node/inlineChatIntent.ts index 3b18a3c50a25d..56c1ec62522de 100644 --- a/extensions/copilot/src/extension/inlineChat2/node/inlineChatIntent.ts +++ b/extensions/copilot/src/extension/inlineChat2/node/inlineChatIntent.ts @@ -40,7 +40,7 @@ import { ICompletedToolCallRound, InlineChat2Prompt, LARGE_FILE_LINE_THRESHOLD } import { ToolName } from '../../tools/common/toolNames'; import { CopilotToolMode } from '../../tools/common/toolsRegistry'; import { isToolValidationError, isValidatedToolInput, IToolsService } from '../../tools/common/toolsService'; -import { InlineChatProgressMessages } from '../../inlineChat/node/progressMessages'; +import { InlineChatProgressMessages } from './progressMessages'; import { CopilotInteractiveEditorResponse, InteractionOutcome } from '../../inlineChat/node/promptCraftingTypes'; diff --git a/extensions/copilot/src/extension/inlineChat/node/progressMessages.ts b/extensions/copilot/src/extension/inlineChat2/node/progressMessages.ts similarity index 99% rename from extensions/copilot/src/extension/inlineChat/node/progressMessages.ts rename to extensions/copilot/src/extension/inlineChat2/node/progressMessages.ts index 28ab04bf81e52..76364ba38e3ef 100644 --- a/extensions/copilot/src/extension/inlineChat/node/progressMessages.ts +++ b/extensions/copilot/src/extension/inlineChat2/node/progressMessages.ts @@ -12,7 +12,7 @@ import { basename } from '../../../util/vs/base/common/resources'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; import { IDocumentContext } from '../../prompt/node/documentContext'; import { renderPromptElement } from '../../prompts/node/base/promptRenderer'; -import { ContextualProgressMessagePrompt, ContextualProgressMessagePromptProps, ProgressMessageScenario, ProgressMessagesPrompt, ProgressMessagesPromptProps } from '../../prompts/node/inline/progressMessages'; +import { ContextualProgressMessagePrompt, ContextualProgressMessagePromptProps, ProgressMessageScenario, ProgressMessagesPrompt, ProgressMessagesPromptProps } from './progressMessagesPrompt'; const MESSAGES_PER_FETCH = 10; const REFETCH_THRESHOLD = 3; diff --git a/extensions/copilot/src/extension/prompts/node/inline/progressMessages.tsx b/extensions/copilot/src/extension/inlineChat2/node/progressMessagesPrompt.tsx similarity index 94% rename from extensions/copilot/src/extension/prompts/node/inline/progressMessages.tsx rename to extensions/copilot/src/extension/inlineChat2/node/progressMessagesPrompt.tsx index 19c3d8c144fb8..6bff2c779039e 100644 --- a/extensions/copilot/src/extension/prompts/node/inline/progressMessages.tsx +++ b/extensions/copilot/src/extension/inlineChat2/node/progressMessagesPrompt.tsx @@ -5,10 +5,11 @@ import { BasePromptElementProps, PromptElement, PromptReference, SystemMessage, UserMessage } from '@vscode/prompt-tsx'; import type { Uri } from 'vscode'; -import { ResponseTranslationRules } from '../base/responseTranslationRules'; -import { SafetyRules } from '../base/safetyRules'; -import { Tag } from '../base/tag'; -import { CodeBlock } from '../panel/safeElements'; +import { SafetyRules } from '../../prompts/node/base/safetyRules'; +import { ResponseTranslationRules } from '../../prompts/node/base/responseTranslationRules'; +import { Tag } from '../../prompts/node/base/tag'; +import { CodeBlock } from '../../prompts/node/panel/safeElements'; + export type ProgressMessageScenario = 'generate' | 'edit'; diff --git a/extensions/copilot/src/extension/inlineEdits/node/nextEditCache.ts b/extensions/copilot/src/extension/inlineEdits/node/nextEditCache.ts index d1f7781fa7371..3659e0c210f89 100644 --- a/extensions/copilot/src/extension/inlineEdits/node/nextEditCache.ts +++ b/extensions/copilot/src/extension/inlineEdits/node/nextEditCache.ts @@ -63,9 +63,26 @@ export interface CachedEdit { * @see CachedEditOpts.cursorOffset */ cursorOffsetAtCacheTime?: number; + /** + * Set to `true` once this cached suggestion has been rendered as an inline + * (ghost text at cursor) suggestion. Used by the "mimic ghost text behavior" + * gating to suppress re-serving the same suggestion in a non-inline form. + */ + wasRenderedAsInlineSuggestion?: boolean; } -export type CachedOrRebasedEdit = CachedEdit & { rebasedEdit?: StringReplacement; rebasedEditIndex?: number; isFromSpeculativeRequest?: boolean }; +export type CachedOrRebasedEdit = CachedEdit & { + rebasedEdit?: StringReplacement; + rebasedEditIndex?: number; + isFromSpeculativeRequest?: boolean; + /** + * When this is a rebased view of a cached edit, points to the underlying + * stored {@link CachedEdit} so that flags such as + * {@link CachedEdit.wasRenderedAsInlineSuggestion} can be persisted on the + * stable cache entry instead of the transient rebased view. + */ + baseCacheEntry?: CachedEdit; +}; export class NextEditCache extends Disposable { private readonly _documentCaches = new Map(); @@ -325,7 +342,7 @@ class DocumentEditCache { if (!cachedEdit.rejected && this.isRejectedNextEdit(currentDocumentContents, res[0].rebasedEdit)) { cachedEdit.rejected = true; } - return { edit: { ...cachedEdit, ...res[0] } }; + return { edit: { ...cachedEdit, ...res[0], baseCacheEntry: cachedEdit } }; } else if (!originalEdits.length) { return { edit: cachedEdit }; // cached 'no edits' } diff --git a/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts b/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts index 5a31c9be113f1..7a4b8585ce615 100644 --- a/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts +++ b/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts @@ -43,7 +43,7 @@ import { NesChangeHint } from '../common/nesTriggerHint'; import { RejectionCollector } from '../common/rejectionCollector'; import { DebugRecorder } from './debugRecorder'; import { INesConfigs } from './nesConfigs'; -import { CachedOrRebasedEdit, NextEditCache } from './nextEditCache'; +import { CachedEdit, CachedOrRebasedEdit, NextEditCache } from './nextEditCache'; import { LlmNESTelemetryBuilder, ReusedRequestKind } from './nextEditProviderTelemetry'; import { INextEditResult, NextEditResult } from './nextEditResult'; import { SpeculativeCancelReason, SpeculativeRequestManager } from './speculativeRequestManager'; @@ -344,6 +344,7 @@ export class NextEditProvider extends Disposable implements INextEditProvider { function createStatelessNextEditProvider(): IStatelessNextEditProvider { return { ID: 'TestNextEditProvider', - provideNextEdit: async function* (request: StatelessNextEditRequest, logger: ILogger, logContext: InlineEditRequestLogContext, cancellationToken: CancellationToken) { + provideNextEdit: async function*(request: StatelessNextEditRequest, logger: ILogger, logContext: InlineEditRequestLogContext, cancellationToken: CancellationToken) { const telemetryBuilder = new StatelessNextEditTelemetryBuilder(request.headerRequestId); const lineEdit = LineEdit.createFromUnsorted( [ @@ -276,4 +276,59 @@ describe('NextEditProvider Caching', () => { ].join('\r\n'); expect(doc.value.get().value).toBe(expectedLines); }); + + it('exposes the cache entry on NextEditResult and preserves the wasRenderedAsInlineSuggestion flag across lookups', async () => { + const obsWorkspace = new MutableObservableWorkspace(); + const obsGit = new ObservableGit(gitExtensionService); + const statelessNextEditProvider = createStatelessNextEditProvider(); + + const nextEditProvider: NextEditProvider = new NextEditProvider(obsWorkspace, statelessNextEditProvider, new NesHistoryContextProvider(obsWorkspace, obsGit), new NesXtabHistoryTracker(obsWorkspace, undefined, configService, expService), undefined, configService, snippyService, logService, expService, requestLogger); + + const doc = obsWorkspace.addDocument({ + id: DocumentId.create(URI.file('/test/test.ts').toString()), + initialValue: outdent` + class Point { + constructor( + private readonly x: number, + private readonly y: number, + ) { } + getDistance() { + return Math.sqrt(this.x ** 2 + this.y ** 2); + } + } + + const myPoint = new Point(0, 1);`.trimStart() + }); + doc.setSelection([new OffsetRange(1, 1)], undefined); + + doc.applyEdit(StringEdit.insert(11, '3D')); + + const context: NESInlineCompletionContext = { triggerKind: 1, selectedCompletionInfo: undefined, requestUuid: generateUuid(), requestIssuedDateTime: Date.now(), earliestShownDateTime: Date.now() + 200, enforceCacheDelay: false }; + const logContext = new InlineEditRequestLogContext(doc.id.toString(), 1, context); + const cancellationToken = CancellationToken.None; + + // First call: edit comes fresh from the (mock) provider but is also cached. + const tb1 = new NextEditProviderTelemetryBuilder(gitExtensionService, mockNotebookService, workspaceService, nextEditProvider.ID, doc); + const first = await nextEditProvider.getNextEdit(doc.id, context, logContext, cancellationToken, tb1.nesBuilder); + tb1.dispose(); + assert(first.result?.edit); + const firstCacheEntry = first.result.cacheEntry; + assert(firstCacheEntry, 'expected a cacheEntry reference on the first (fresh) NextEditResult'); + expect(firstCacheEntry.wasRenderedAsInlineSuggestion).toBeFalsy(); + + // Simulate the inline-completion-provider marking the entry as having been + // rendered as an inline (ghost text) suggestion. + firstCacheEntry.wasRenderedAsInlineSuggestion = true; + + // Second call (no document changes): we should still get the same cached + // edit back, and the flag must have been preserved on the same entry. + const tb2 = new NextEditProviderTelemetryBuilder(gitExtensionService, mockNotebookService, workspaceService, nextEditProvider.ID, doc); + const second = await nextEditProvider.getNextEdit(doc.id, context, logContext, cancellationToken, tb2.nesBuilder); + tb2.dispose(); + assert(second.result?.edit); + const secondCacheEntry = second.result.cacheEntry; + assert(secondCacheEntry, 'expected a cacheEntry reference on the second (cached) NextEditResult'); + expect(secondCacheEntry).toBe(firstCacheEntry); + expect(secondCacheEntry.wasRenderedAsInlineSuggestion).toBe(true); + }); }); diff --git a/extensions/copilot/src/extension/inlineEdits/vscode-node/inlineCompletionProvider.ts b/extensions/copilot/src/extension/inlineEdits/vscode-node/inlineCompletionProvider.ts index f19d7d44be50f..8d9df088a1313 100644 --- a/extensions/copilot/src/extension/inlineEdits/vscode-node/inlineCompletionProvider.ts +++ b/extensions/copilot/src/extension/inlineEdits/vscode-node/inlineCompletionProvider.ts @@ -35,7 +35,6 @@ import { autorun, IObservable, observableFromEvent } from '../../../util/vs/base import { basename } from '../../../util/vs/base/common/path'; import { StringEdit } from '../../../util/vs/editor/common/core/edits/stringEdit'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; -import { LineCheck } from './naturalLanguageHint'; import { createCorrelationId } from '../common/correlationId'; import { NesChangeHint } from '../common/nesTriggerHint'; import { NESInlineCompletionContext } from '../node/nextEditProvider'; @@ -48,6 +47,7 @@ import { DiagnosticsNextEditResult } from './features/diagnosticsInlineEditProvi import { InlineEditModel } from './inlineEditModel'; import { learnMoreCommandId, learnMoreLink } from './inlineEditProviderFeature'; import { toInlineSuggestion } from './isInlineSuggestion'; +import { LineCheck } from './naturalLanguageHint'; import { InlineEditLogger } from './parts/inlineEditLogger'; import { IVSCodeObservableDocument } from './parts/vscodeWorkspace'; import { raceAndAll } from './raceAndAll'; @@ -64,6 +64,12 @@ export interface NesCompletionItem extends InlineCompletionItem { readonly info: NesCompletionInfo; wasShown: boolean; isEditInAnotherDocument?: boolean; + /** + * Whether the underlying NES suggestion is being served as an inline (ghost + * text at cursor) suggestion as opposed to a non-inline NES (e.g. gutter or + * side hint). Used by the "mimic ghost text behavior" gating. + */ + isInlineCompletion?: boolean; } export class NesCompletionList extends InlineCompletionList { @@ -147,6 +153,7 @@ export class InlineCompletionProviderImpl extends Disposable implements InlineCo private readonly _displayNextEditorNES: boolean; private readonly _renameSymbolSuggestions: IObservable; private readonly _inlineCompletionsAdvanced: IObservable; + private readonly _nesMimicGhostTextBehavior: IObservable; constructor( private readonly model: InlineEditModel, @@ -172,6 +179,7 @@ export class InlineCompletionProviderImpl extends Disposable implements InlineCo this._displayNextEditorNES = this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.UseAlternativeNESNotebookFormat, this._expService); this._renameSymbolSuggestions = this._configurationService.getExperimentBasedConfigObservable(ConfigKey.Advanced.InlineEditsRenameSymbolSuggestions, this._expService); this._inlineCompletionsAdvanced = this._configurationService.getExperimentBasedConfigObservable(ConfigKey.TeamInternal.InlineEditsInlineCompletionsAdvanced, this._expService); + this._nesMimicGhostTextBehavior = this._configurationService.getExperimentBasedConfigObservable(ConfigKey.TeamInternal.InlineEditsNesMimicGhostTextBehavior, this._expService); this.setCurrentModelId = (modelId: string) => this._modelService.setCurrentModelId(modelId); @@ -431,6 +439,22 @@ export class InlineCompletionProviderImpl extends Disposable implements InlineCo }; } + // Gate: when the "mimic ghost text behavior" setting is on, a cached suggestion + // that was previously rendered as an inline (ghost text) suggestion must not + // re-surface in any other form. Suppress here without evicting the cache entry — + // when the cursor returns to an inline-renderable position, we'll serve it again. + if ( + this._nesMimicGhostTextBehavior.get() + && !isInlineCompletion + && isLlmCompletionInfo(suggestionInfo) + && suggestionInfo.suggestion.result?.cacheEntry?.wasRenderedAsInlineSuggestion + ) { + logger.trace('Return: previously shown as inline; current context cannot render as inline'); + telemetryBuilder.setStatus('noEdit:suppressedNonInlineReshow'); + this.telemetrySender.scheduleSendingEnhancedTelemetry(suggestionInfo.suggestion, telemetryBuilder); + return emptyList; + } + if (!completionItem) { this.telemetrySender.scheduleSendingEnhancedTelemetry(suggestionInfo.suggestion, telemetryBuilder); return emptyList; @@ -463,6 +487,7 @@ export class InlineCompletionProviderImpl extends Disposable implements InlineCo telemetryBuilder, action: learnMoreAction, isInlineEdit: !isInlineCompletion, + isInlineCompletion, showInlineEditMenu: !(unification && isInlineCompletion), wasShown: false, supportsRename, @@ -554,6 +579,15 @@ export class InlineCompletionProviderImpl extends Disposable implements InlineCo this.logContextRecorder?.handleShown(info.suggestion); if (isLlmCompletionInfo(info)) { + // Mark the underlying cache entry as having been rendered as an inline + // (ghost text) suggestion. The "mimic ghost text behavior" gate uses this + // flag to suppress re-serving the same suggestion in a non-inline form. + if (completionItem.isInlineCompletion) { + const cacheEntry = info.suggestion.result?.cacheEntry; + if (cacheEntry) { + cacheEntry.wasRenderedAsInlineSuggestion = true; + } + } this.model.nextEditProvider.handleShown(info.suggestion); } else { this.model.diagnosticsBasedProvider?.handleShown(info.suggestion); diff --git a/extensions/copilot/src/extension/intents/node/agentIntent.ts b/extensions/copilot/src/extension/intents/node/agentIntent.ts index 8f45fbdbfc6ad..0065d481a060a 100644 --- a/extensions/copilot/src/extension/intents/node/agentIntent.ts +++ b/extensions/copilot/src/extension/intents/node/agentIntent.ts @@ -397,7 +397,7 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I @ILogService private readonly logService: ILogService, @IExperimentationService private readonly expService: IExperimentationService, @IAutomodeService private readonly automodeService: IAutomodeService, - @IOTelService override readonly otelService: IOTelService, + @IOTelService protected override readonly otelService: IOTelService, @ISessionTranscriptService private readonly sessionTranscriptService: ISessionTranscriptService, ) { super(intent, location, endpoint, request, intentOptions, instantiationService, codeMapperService, envService, promptPathRepresentationService, endpointProvider, workspaceService, toolsService, configurationService, editLogService, commandService, telemetryService, notebookService, otelService); diff --git a/extensions/copilot/src/extension/intents/node/newNotebookIntent.ts b/extensions/copilot/src/extension/intents/node/newNotebookIntent.ts index 9810e9008c68e..8d72c14d1dbbd 100644 --- a/extensions/copilot/src/extension/intents/node/newNotebookIntent.ts +++ b/extensions/copilot/src/extension/intents/node/newNotebookIntent.ts @@ -178,6 +178,7 @@ export class NewNotebookResponseProcessor { const sourceLines = filterFilePathFromCodeBlock2(streamLines(sourceStream.asyncIterable) .filter(LineFilters.createCodeBlockFilter()) .map(line => { + // eslint-disable-next-line local/code-no-unused-expressions newNotebook.value; // force the notebook to be created return line; })); diff --git a/extensions/copilot/src/extension/onboardDebug/test/node/debuggableCommandIdentifier.spec.ts b/extensions/copilot/src/extension/onboardDebug/test/node/debuggableCommandIdentifier.spec.ts index 6c6f62552a929..3af61b2732316 100644 --- a/extensions/copilot/src/extension/onboardDebug/test/node/debuggableCommandIdentifier.spec.ts +++ b/extensions/copilot/src/extension/onboardDebug/test/node/debuggableCommandIdentifier.spec.ts @@ -2,6 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +/* eslint-disable local/code-no-unused-expressions */ import { SinonStub, stub } from 'sinon'; diff --git a/extensions/copilot/src/extension/prompt/test/node/conversation.spec.ts b/extensions/copilot/src/extension/prompt/test/node/conversation.spec.ts index 493aafc980f33..86180d4cc74cc 100644 --- a/extensions/copilot/src/extension/prompt/test/node/conversation.spec.ts +++ b/extensions/copilot/src/extension/prompt/test/node/conversation.spec.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ - +/* eslint-disable local/code-no-unused-expressions */ import { describe, expect, it } from 'vitest'; import type { ChatResult } from 'vscode'; import { ChatVariablesCollection } from '../../common/chatVariablesCollection'; diff --git a/extensions/copilot/src/extension/prompts/node/panel/startDebugging.tsx b/extensions/copilot/src/extension/prompts/node/panel/startDebugging.tsx index 637902f654ace..2cfa7e7507bcd 100644 --- a/extensions/copilot/src/extension/prompts/node/panel/startDebugging.tsx +++ b/extensions/copilot/src/extension/prompts/node/panel/startDebugging.tsx @@ -382,6 +382,7 @@ export class StartDebuggingPrompt extends PromptElement 0 && <>Below is a list of information from the Visual Studio Code documentation which might be relevant to the question.
} {state.docSearchResults && state.docSearchResults.map((result) => { if (result?.title && result.contents) { + // eslint-disable-next-line local/code-no-unused-expressions ##{result?.title?.trim()} - {result.path}
{result.contents} diff --git a/extensions/copilot/src/extension/tools/node/editFileToolUtils.tsx b/extensions/copilot/src/extension/tools/node/editFileToolUtils.tsx index 29eb57384e263..fcac1ac97541a 100644 --- a/extensions/copilot/src/extension/tools/node/editFileToolUtils.tsx +++ b/extensions/copilot/src/extension/tools/node/editFileToolUtils.tsx @@ -6,6 +6,7 @@ import { t } from '@vscode/l10n'; import { realpath } from 'fs/promises'; import { homedir } from 'os'; +import * as path from 'path'; import type { LanguageModelChat, PreparedToolInvocation } from 'vscode'; import { IConfigurationService } from '../../../platform/configuration/common/configurationService'; import { ICustomInstructionsService } from '../../../platform/customInstructions/common/customInstructionsService'; @@ -892,7 +893,29 @@ export function makeUriConfirmationChecker(configuration: IConfigurationService, const toCheck = [normalizePath(uri)]; if (uri.scheme === Schemas.file) { try { - const linked = await realpath(uri.fsPath); + let linked: string; + try { + linked = await realpath(uri.fsPath); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') { + // File doesn't exist yet (e.g. CreateFileTool case) — resolve the + // parent directory so symlinked parents are still checked. + const parentDir = path.dirname(uri.fsPath); + try { + const resolvedParent = await realpath(parentDir); + linked = path.join(resolvedParent, path.basename(uri.fsPath)); + } catch (parentError) { + const code = (parentError as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') { + linked = uri.fsPath; + } else { + throw parentError; + } + } + } else { + throw e; + } + } assertPathIsSafe(linked); if (linked !== uri.fsPath) { @@ -902,7 +925,7 @@ export function makeUriConfirmationChecker(configuration: IConfigurationService, if ((e as NodeJS.ErrnoException).code === 'EPERM') { return ConfirmationCheckResult.NoPermissions; } - // Usually EPERM or ENOENT on the linkedFile + // Usually EPERM on the linkedFile } } diff --git a/extensions/copilot/src/extension/tools/node/test/toolTestUtils.tsx b/extensions/copilot/src/extension/tools/node/test/toolTestUtils.tsx index c05af74e1f25d..c1350fe1c3ceb 100644 --- a/extensions/copilot/src/extension/tools/node/test/toolTestUtils.tsx +++ b/extensions/copilot/src/extension/tools/node/test/toolTestUtils.tsx @@ -24,6 +24,7 @@ export async function renderElementToString(accessor: ServicesAccessor, element: }; const endpoint = await accessor.get(IEndpointProvider).getChatEndpoint('copilot-base'); + // eslint-disable-next-line local/code-no-accessor-after-await const renderer = PromptRenderer.create(accessor.get(IInstantiationService), endpoint, clz, {}); const r = await renderer.render(); diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/languageContextService.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/languageContextService.ts index fdb2488c7d9d5..1b68fa36c0b57 100644 --- a/extensions/copilot/src/extension/typescriptContext/vscode-node/languageContextService.ts +++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/languageContextService.ts @@ -1397,6 +1397,7 @@ export class LanguageContextServiceImpl implements ILanguageContextService, vsco } contextItemResult.updateResponse(body, token); this.telemetrySender.sendRequestTelemetry(document, position, context, contextItemResult, timeTaken, { before: cacheState, after: this.runnableResultManager.getCacheState() }, undefined); + // eslint-disable-next-line local/code-no-unused-expressions isDebugging && forDebugging?.length; this._onCachePopulated.fire({ document, position, source: context.source, items: resolved, summary: contextItemResult }); } else if (protocol.ComputeContextResponse.isError(response)) { @@ -1524,6 +1525,7 @@ export class LanguageContextServiceImpl implements ILanguageContextService, vsco document, position, context, contextItemResult, Date.now() - startTime, { before: cacheState, after: cacheState }, cacheRequest ); + // eslint-disable-next-line local/code-no-unused-expressions isDebugging && forDebugging?.length; this._onContextComputed.fire({ document, position, source: context.source, items: itemsToYield, summary: contextItemResult diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index dd15f344c6d03..78c8e4132a347 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -753,6 +753,14 @@ export namespace ConfigKey { export const InlineEditsNextCursorPredictionLintOptions = defineTeamInternalSetting | undefined>('chat.advanced.inlineEdits.nextCursorPrediction.lintOptions', ConfigType.Simple, undefined, xtabPromptOptions.LINT_OPTIONS_VALIDATOR); export const InlineEditsInlineCompletionsEnabled = defineTeamInternalSetting('chat.advanced.inlineEdits.inlineCompletions.enabled', ConfigType.Simple, true, vBoolean()); export const InlineEditsInlineCompletionsAdvanced = defineTeamInternalSetting('chat.advanced.inlineEdits.inlineCompletions.advancedDetection', ConfigType.ExperimentBased, true, vBoolean()); + /** + * When enabled, a cached NES suggestion that was once rendered as an inline + * (ghost text at cursor) suggestion will not be re-served from cache unless + * it can again be rendered as an inline suggestion. The cache entry is not + * evicted — it is simply gated until the cursor returns to an + * inline-renderable position. + */ + export const InlineEditsNesMimicGhostTextBehavior = defineTeamInternalSetting('chat.advanced.inlineEdits.nesMimicGhostTextBehavior', ConfigType.ExperimentBased, false, vBoolean()); export const InlineEditsXtabProviderUsePrediction = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.usePrediction', ConfigType.ExperimentBased, true, vBoolean()); export const InlineEditsXtabLanguageContextEnabledLanguages = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.languageContext.enabledLanguages', ConfigType.Simple, LANGUAGE_CONTEXT_ENABLED_LANGUAGES); export const InlineEditsXtabLanguageContextTraitsPosition = defineTeamInternalSetting<'before' | 'after'>('chat.advanced.inlineEdits.xtabProvider.languageContext.traitsPosition', ConfigType.ExperimentBased, 'before'); diff --git a/extensions/copilot/src/platform/endpoint/test/node/testEndpointProvider.ts b/extensions/copilot/src/platform/endpoint/test/node/testEndpointProvider.ts index e06e47eba7d1a..48a119bb67ccc 100644 --- a/extensions/copilot/src/platform/endpoint/test/node/testEndpointProvider.ts +++ b/extensions/copilot/src/platform/endpoint/test/node/testEndpointProvider.ts @@ -3,8 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable import/no-restricted-paths */ - import type { ChatRequest, LanguageModelChat } from 'vscode'; import { CacheableRequest, SQLiteCache } from '../../../../../test/base/cache'; import { TestingCacheSalts } from '../../../../../test/base/salts'; diff --git a/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts b/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts index ccee88711f1e8..690e15a308bab 100644 --- a/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts +++ b/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts @@ -447,6 +447,7 @@ export class GitServiceImpl extends Disposable implements IGitService { onDidChangeStateSignal.read(reader); const selected = selectedObs.read(reader); + // eslint-disable-next-line local/code-no-observable-get-in-reactive-context const activeRepository = this.activeRepository.get(); if (activeRepository && !selected && !isEqual(activeRepository.rootUri, repository.rootUri)) { return; diff --git a/extensions/copilot/src/platform/telemetry/common/ghTelemetrySender.ts b/extensions/copilot/src/platform/telemetry/common/ghTelemetrySender.ts index 24226e4a0f349..248a5eabfef42 100644 --- a/extensions/copilot/src/platform/telemetry/common/ghTelemetrySender.ts +++ b/extensions/copilot/src/platform/telemetry/common/ghTelemetrySender.ts @@ -17,7 +17,7 @@ import { TelemetryData, eventPropertiesToSimpleObject } from '../common/telemetr export class BaseGHTelemetrySender implements ITelemetrySender { - protected _disposables: DisposableStore = new DisposableStore(); + protected readonly _disposables: DisposableStore = new DisposableStore(); private _standardTelemetryLogger: TelemetryLogger; private _enhancedTelemetryLogger?: TelemetryLogger; diff --git a/extensions/copilot/src/platform/test/node/extensionContext.ts b/extensions/copilot/src/platform/test/node/extensionContext.ts index f8ecedc2c1f21..71dcd65fa07f7 100644 --- a/extensions/copilot/src/platform/test/node/extensionContext.ts +++ b/extensions/copilot/src/platform/test/node/extensionContext.ts @@ -62,7 +62,7 @@ function constructGlobalStoragePath(globalStoragePath: string): URI { } export class MockExtensionContext implements BrandedService { - _serviceBrand = undefined; + declare _serviceBrand: undefined; extension = { id: 'GitHub.copilot-chat' } as any; extensionUri = URI.from({ scheme: 'file', path: '/mock-extension' }); extensionMode = ExtensionMode.Test; diff --git a/extensions/copilot/src/platform/testing/test/node/setupTestDetector.spec.ts b/extensions/copilot/src/platform/testing/test/node/setupTestDetector.spec.ts index 0e169594ec80e..cf8b6d91b7735 100644 --- a/extensions/copilot/src/platform/testing/test/node/setupTestDetector.spec.ts +++ b/extensions/copilot/src/platform/testing/test/node/setupTestDetector.spec.ts @@ -2,6 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +/* eslint-disable local/code-no-unused-expressions */ import { beforeEach, expect, suite, test, vi } from 'vitest'; import type * as vscode from 'vscode'; diff --git a/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestClient.ts b/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestClient.ts index 1de527eb26a02..988ec5855d3f5 100644 --- a/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestClient.ts +++ b/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestClient.ts @@ -37,14 +37,26 @@ export interface ExternalIngestUpdateIndexResult { readonly updatedFileCount: number; } +export interface ExternalIngestFileSet { + readonly files: readonly ExternalIngestFile[]; + readonly checkpoint: string; +} + +export function computeCheckpointHash(files: readonly { readonly docSha: Uint8Array }[]): string { + const hash = crypto.createHash('sha1'); + for (const file of files) { + hash.update(file.docSha); + } + return hash.digest().toString('base64'); +} + /** * Interface for the external ingest client that handles indexing and searching files. */ export interface IExternalIngestClient { updateIndex( filesetName: string, - currentCheckpoint: string | undefined, - allFiles: AsyncIterable, + fileSet: ExternalIngestFileSet, callTracker: CallTracker, token: CancellationToken, onProgress?: (message: string) => void @@ -160,7 +172,7 @@ export class ExternalIngestClient extends Disposable implements IExternalIngestC throw new ExternalIngestRequestError(`${method} ${pathId} failed with status ${response.status}`, response); } - async updateIndex(filesetName: string, currentCheckpoint: string | undefined, allFiles: AsyncIterable, inCallTracker: CallTracker, token: CancellationToken, onProgress?: (message: string) => void): Promise> { + async updateIndex(filesetName: string, fileSet: ExternalIngestFileSet, inCallTracker: CallTracker, token: CancellationToken, onProgress?: (message: string) => void): Promise> { const callTracker = inCallTracker.add('ExternalIngestClient::updateIndex'); const authToken = await raceCancellationError(this.getAuthToken(), token); if (!authToken) { @@ -168,6 +180,8 @@ export class ExternalIngestClient extends Disposable implements IExternalIngestC return Result.error(new Error('No auth token available')); } + const { files: allFiles, checkpoint: newCheckpoint } = fileSet; + // Initial setup const mappings = new Map(); const geoFilter = new ingestUtils.GeoFilter(); @@ -179,7 +193,7 @@ export class ExternalIngestClient extends Disposable implements IExternalIngestC const ingestableCheckStart = performance.now(); const allDocShas: Uint8Array[] = []; - for await (const file of allFiles) { + for (const file of allFiles) { if (token.isCancellationRequested) { throw new CancellationError(); } @@ -197,19 +211,6 @@ export class ExternalIngestClient extends Disposable implements IExternalIngestC // TODO: this range should be the entire fileset, right? const codedSymbols = ingestUtils.createCodedSymbols(allDocShas, 0, 1).map((cs) => Buffer.from(cs).toString('base64')); - // A hash of all docsha hashes. This emulates a differing git commit. - const checkpointHash = crypto.createHash('sha1'); - for (const docSha of allDocShas) { - checkpointHash.update(docSha); - - } - const newCheckpoint = checkpointHash.digest().toString('base64'); - - if (newCheckpoint === currentCheckpoint) { - this.logService.info('ExternalIngestClient::updateIndex(): Checkpoint matches current checkpoint, skipping ingest.'); - return Result.ok({ checkpoint: newCheckpoint, totalFileCount: mappings.size, updatedFileCount: 0 }); - } - // Retry loop for 409 Conflict: per the external indexing spec, if any ingestion // endpoint returns 409, discard the ingest_id and restart from CreateCheckpoint. const maxConflictRetries = 3; diff --git a/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestIndex.ts b/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestIndex.ts index 8619ea64b029b..7dd3c0835a7f6 100644 --- a/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestIndex.ts +++ b/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/externalIngestIndex.ts @@ -39,7 +39,7 @@ import { IWorkspaceService } from '../../../workspace/common/workspaceService'; import { StrategySearchSizing, WorkspaceChunkQueryWithEmbeddings } from '../../common/workspaceChunkSearch'; import { shouldPotentiallyIndexFile } from '../workspaceFileIndex'; import { CodeSearchRepoStatus, TriggerIndexingError, TriggerRemoteIndexingError } from './codeSearchRepo'; -import { ExternalIngestFile, ExternalIngestRequestError, IExternalIngestClient } from './externalIngestClient'; +import { computeCheckpointHash, ExternalIngestFile, ExternalIngestFileSet, ExternalIngestRequestError, IExternalIngestClient } from './externalIngestClient'; import { WorkspaceFolderIdMap } from './workspaceFolderIdMap'; const debug = false; @@ -110,6 +110,8 @@ export class ExternalIngestIndex extends Disposable { progressMessage: string | undefined; completed: boolean; + + readonly checkpointHash: string; }; /** @@ -322,11 +324,35 @@ export class ExternalIngestIndex extends Disposable { const currentCheckpoint = this.getCurrentIndexCheckpoint(); + // Pre-collect all files and compute the checkpoint hash so we can + // detect whether the workspace state has actually changed. + const allFiles: ExternalIngestFile[] = []; + for await (const file of this.getFilesToIndexFromDb(callerToken)) { + allFiles.push(file); + } + const checkpointHash = computeCheckpointHash(allFiles); + + const fileSet: ExternalIngestFileSet = { files: allFiles, checkpoint: checkpointHash }; + + // If the checkpoint matches the stored one, the index is already up to date. + if (checkpointHash === currentCheckpoint) { + this._logService.info('ExternalIngestIndex::doIngest(): Checkpoint matches current checkpoint, skipping ingest.'); + return Result.ok(true); + } + + // If there is a running operation with the same checkpoint hash, + // the workspace state has not changed — reuse the existing operation. + if (this._currentIngestOperation && !this._currentIngestOperation.completed && this._currentIngestOperation.checkpointHash === checkpointHash) { + this._logService.info('ExternalIngestIndex::doIngest(): Workspace state unchanged, reusing existing ingest operation'); + return this._currentIngestOperation.promise; + } + // Track building state const operation: typeof this._currentIngestOperation = { promise: undefined!, progressMessage: undefined, completed: false, + checkpointHash, }; const sw = new StopWatch(); @@ -346,8 +372,7 @@ export class ExternalIngestIndex extends Disposable { try { const result = await this._client.updateIndex( filesetName, - currentCheckpoint, - this.getFilesToIndexFromDb(token), + fileSet, telemetryInfo.callTracker, token, wrappedOnProgress @@ -425,7 +450,7 @@ export class ExternalIngestIndex extends Disposable { } }); - // Cancel existing + // Cancel existing since workspace state has changed this._currentIngestOperation?.promise.cancel(); operation.promise = updatePromise; @@ -801,8 +826,9 @@ export class ExternalIngestIndex extends Disposable { } private async *getFilesToIndexFromDb(token: CancellationToken): AsyncIterable { - // Get files that are either already marked "Yes" or "need to be evaluated" (Undetermined) - const rows = this._db.prepare('SELECT path, size, mtime, docSha, shouldIngest FROM Files WHERE shouldIngest IN (?, ?)').all(ShouldIngestState.Yes, ShouldIngestState.Undetermined) as unknown as Array; + // Get files that are either already marked "Yes" or "need to be evaluated" (Undetermined). + // Order by path for deterministic results (important for stable checkpoint hashes). + const rows = this._db.prepare('SELECT path, size, mtime, docSha, shouldIngest FROM Files WHERE shouldIngest IN (?, ?) ORDER BY path').all(ShouldIngestState.Yes, ShouldIngestState.Undetermined) as unknown as Array; const limiter = new Limiter(20); diff --git a/extensions/copilot/src/platform/workspaceChunkSearch/node/workspaceChunkSearchService.ts b/extensions/copilot/src/platform/workspaceChunkSearch/node/workspaceChunkSearchService.ts index abd24a2088299..0c77092b995c3 100644 --- a/extensions/copilot/src/platform/workspaceChunkSearch/node/workspaceChunkSearchService.ts +++ b/extensions/copilot/src/platform/workspaceChunkSearch/node/workspaceChunkSearchService.ts @@ -105,6 +105,7 @@ export class WorkspaceChunkSearchService extends Disposable implements IWorkspac @IAuthenticationService private readonly _authenticationService: IAuthenticationService, @IGithubAvailableEmbeddingTypesService private readonly _availableEmbeddingTypes: IGithubAvailableEmbeddingTypesService, @ILogService private readonly _logService: ILogService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { super(); @@ -124,10 +125,14 @@ export class WorkspaceChunkSearchService extends Disposable implements IWorkspac return this._impl; } + const startTime = Date.now(); + type TryInitOutcome = 'success' | 'noEmbeddingType' | 'alreadyInitialized' | 'error'; + let outcome: TryInitOutcome = 'noEmbeddingType'; try { const best = await this._availableEmbeddingTypes.getPreferredType(silent); // Double check that we haven't initialized in the meantime if (this._impl) { + outcome = 'alreadyInitialized'; return this._impl; } @@ -136,11 +141,29 @@ export class WorkspaceChunkSearchService extends Disposable implements IWorkspac this._impl = this._register(this._instantiationService.createInstance(WorkspaceChunkSearchServiceImpl, best)); this._register(this._impl.onDidChangeIndexState(() => this._onDidChangeIndexState.fire())); this._onDidChangeIndexState.fire(); + outcome = 'success'; return this._impl; } } catch { + outcome = 'error'; return undefined; + } finally { + /* __GDPR__ + "workspaceChunkSearch.tryInit" : { + "owner": "mjbvz", + "comment": "Tracks cold workspace chunk search initialization duration and outcome. Not fired for fast paths (no auth, already initialized).", + "durationMs": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Time in milliseconds for getPreferredType and initialization" }, + "outcome": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The outcome: success, noEmbeddingType, alreadyInitialized, or error" }, + "silent": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether this was a silent initialization attempt" } + } + */ + this._telemetryService.sendMSFTTelemetryEvent('workspaceChunkSearch.tryInit', { + outcome, + silent: String(silent), + }, { + durationMs: Date.now() - startTime, + }); } } diff --git a/extensions/copilot/src/platform/workspaceChunkSearch/test/node/externalIngest.spec.ts b/extensions/copilot/src/platform/workspaceChunkSearch/test/node/externalIngest.spec.ts index 68d06877f71df..56a400c581216 100644 --- a/extensions/copilot/src/platform/workspaceChunkSearch/test/node/externalIngest.spec.ts +++ b/extensions/copilot/src/platform/workspaceChunkSearch/test/node/externalIngest.spec.ts @@ -19,7 +19,7 @@ import { FileType } from '../../../filesystem/common/fileTypes'; import { ISearchService } from '../../../search/common/searchService'; import { createPlatformServices, TestingServiceCollection } from '../../../test/node/services'; import { IWorkspaceService, NullWorkspaceService } from '../../../workspace/common/workspaceService'; -import { ExternalIngestClient, ExternalIngestFile, ExternalIngestUpdateIndexResult, IExternalIngestClient } from '../../node/codeSearch/externalIngestClient'; +import { ExternalIngestClient, ExternalIngestFile, ExternalIngestFileSet, ExternalIngestUpdateIndexResult, IExternalIngestClient } from '../../node/codeSearch/externalIngestClient'; import { ExternalIngestIndex } from '../../node/codeSearch/externalIngestIndex'; const emptyProgressCb: (message: string) => void = () => { }; @@ -40,8 +40,8 @@ function createMockExternalIngestClient(options?: { return Array.from(ingestedFiles.values()); }, searchCalls, - async updateIndex(_filesetName: string, _currentCheckpoint: string | undefined, allFiles: AsyncIterable, _callTracker: CallTracker, _token: CancellationToken, _onProgress?: (message: string) => void): Promise> { - for await (const file of allFiles) { + async updateIndex(_filesetName: string, fileSet: ExternalIngestFileSet, _callTracker: CallTracker, _token: CancellationToken, _onProgress?: (message: string) => void): Promise> { + for (const file of fileSet.files) { ingestedFiles.set(file.uri, file); } return Result.ok({ checkpoint: 'mock-checkpoint', totalFileCount: ingestedFiles.size, updatedFileCount: ingestedFiles.size }); diff --git a/extensions/copilot/test/base/extHostContext/simulationExtHostContext.ts b/extensions/copilot/test/base/extHostContext/simulationExtHostContext.ts index b47dc27a2b4e5..4ba0acf97d9a4 100644 --- a/extensions/copilot/test/base/extHostContext/simulationExtHostContext.ts +++ b/extensions/copilot/test/base/extHostContext/simulationExtHostContext.ts @@ -2,9 +2,6 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// Allow importing vscode here. eslint does not let us exclude this path: https://github.com/import-js/eslint-plugin-import/issues/2800 -/* eslint-disable import/no-restricted-paths */ - import { GitDiffService } from '../../../src/extension/prompt/vscode-node/gitDiffService'; import { IExtensionsService } from '../../../src/platform/extensions/common/extensionsService'; import { VSCodeExtensionsService } from '../../../src/platform/extensions/vscode/extensionsService'; diff --git a/extensions/copilot/test/base/extHostContext/simulationExtHostToolsService.ts b/extensions/copilot/test/base/extHostContext/simulationExtHostToolsService.ts index 0cd2eb1b96b87..259549d78757f 100644 --- a/extensions/copilot/test/base/extHostContext/simulationExtHostToolsService.ts +++ b/extensions/copilot/test/base/extHostContext/simulationExtHostToolsService.ts @@ -2,8 +2,6 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// Allow importing vscode here. eslint does not let us exclude this path: https://github.com/import-js/eslint-plugin-import/issues/2800 -/* eslint-disable import/no-restricted-paths */ import type { CancellationToken, ChatRequest, LanguageModelTool, LanguageModelToolInformation, LanguageModelToolInvocationOptions, LanguageModelToolResult } from 'vscode'; import { getToolName, ToolName } from '../../../src/extension/tools/common/toolNames'; diff --git a/extensions/copilot/test/base/extHostContext/simulationWorkspaceExtHost.ts b/extensions/copilot/test/base/extHostContext/simulationWorkspaceExtHost.ts index 455b7a6f16838..b6869227b4c30 100644 --- a/extensions/copilot/test/base/extHostContext/simulationWorkspaceExtHost.ts +++ b/extensions/copilot/test/base/extHostContext/simulationWorkspaceExtHost.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ // Allow importing vscode here. eslint does not let us exclude this path: https://github.com/import-js/eslint-plugin-import/issues/2800 -/* eslint-disable local/no-runtime-import */ +/* eslint-disable copilot-local/no-runtime-import */ import { writeFileSync } from 'fs'; import * as vscode from 'vscode'; diff --git a/extensions/copilot/vite.config.ts b/extensions/copilot/vite.config.ts index 44b5f01ea2279..c5c75abcc39cc 100644 --- a/extensions/copilot/vite.config.ts +++ b/extensions/copilot/vite.config.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// eslint-disable-next-line no-restricted-imports import * as path from 'path'; import { loadEnv } from 'vite'; import topLevelAwait from 'vite-plugin-top-level-await'; diff --git a/package-lock.json b/package-lock.json index 5a4505d0ff2c0..8cdbd852d3802 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "license": "MIT", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.49", - "@github/copilot": "^1.0.38", + "@github/copilot": "1.0.34", "@github/copilot-sdk": "^0.2.2", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", @@ -119,6 +119,7 @@ "eslint": "^9.36.0", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", + "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsdoc": "^50.3.1", "event-stream": "3.3.4", "fancy-log": "^1.3.3", @@ -1072,26 +1073,26 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.38.tgz", - "integrity": "sha512-GjtKCiFczeKuECOuxkBkJYb8estSnhxgh4iQ9BTkWg4y3EWYl2VaMCXCu9KkVPf/fwy/URt1l8Rf4M4tZxVZAA==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.34.tgz", + "integrity": "sha512-jFYulj1v00b3j43Er9+WwhZ/XldGq7+gti2s2pRhrdPwYEd1PMvscDZwRa/1iUBz/XQ5HUGac1tD8P7+VUpWjg==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.38", - "@github/copilot-darwin-x64": "1.0.38", - "@github/copilot-linux-arm64": "1.0.38", - "@github/copilot-linux-x64": "1.0.38", - "@github/copilot-win32-arm64": "1.0.38", - "@github/copilot-win32-x64": "1.0.38" + "@github/copilot-darwin-arm64": "1.0.34", + "@github/copilot-darwin-x64": "1.0.34", + "@github/copilot-linux-arm64": "1.0.34", + "@github/copilot-linux-x64": "1.0.34", + "@github/copilot-win32-arm64": "1.0.34", + "@github/copilot-win32-x64": "1.0.34" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.38.tgz", - "integrity": "sha512-JyzyQ/VUC30QBOnOoqBbfAlMbIycKVqIOepeTdArNk+oER8qfQ9LqQPxA6FDqCQl3GAMclzqZGL9jK7I2WldhA==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.34.tgz", + "integrity": "sha512-g94EhSLd3a6fckZ6xb/zP2DZJZEx7kONWdOoDiHXUtSqc4RiZ7OBq1EwT4WrPY1lsmy9sioJIcZSGzJd0C1M7Q==", "cpu": [ "arm64" ], @@ -1105,9 +1106,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.38.tgz", - "integrity": "sha512-2Wv/4KPY2XC6JRGvJzavrk/RBmbH3Z5pNZZslL0BW2+AeZsoYqmVrA/1pxUs+KSVaGDC420dqS7uZ6u/mg23oQ==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.34.tgz", + "integrity": "sha512-tIgFEZV0ohCF/VgTODJWre3xURsvEd+6IPN/HPKWxG6AXtJOxzjlr5kLYYdPHdNlHNmSxGQw8fWsN2FZ4nyDdw==", "cpu": [ "x64" ], @@ -1121,9 +1122,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.38.tgz", - "integrity": "sha512-s+rNuvL3pKkZ6orZZoKcsbNDlu79f6/EBj5ovo2pJ6iBI3YMNwUM8AZq9pcFUpZCaLJ6E7GGZoujRMbpjKP/wQ==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.34.tgz", + "integrity": "sha512-feqjEetrlqBUhYskIsPmwACQOWO99cvRpKwIFl3OlEjWoj+//HA7yXh49UIe0gD8wQUI8hy05uVz3K2/xti2nQ==", "cpu": [ "arm64" ], @@ -1137,9 +1138,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.38.tgz", - "integrity": "sha512-8aAXJ0Qv+4naW4FcsqQNzgGykaiYe5q7ZO55ZuUMQ92ZY+Kae5kTttwiZ325T9CdeNHVT9f+aMx8gAGVWxfvFg==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.34.tgz", + "integrity": "sha512-3l0rZZqmceklHizJaaO+Iy2PsAZpVZS9Mn9VYnVcY/8Yzt4Y2hmXSFcKVfc4l+JlhFsPs7trhMdIkfwkjaKPLg==", "cpu": [ "x64" ], @@ -1176,9 +1177,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.38.tgz", - "integrity": "sha512-M7Da1h25IsnYyw9LBCatxgQUsu+C5+xJsHMZeR8dnxRF/kt75Ksqk1+pWp8oBk1BqK9ahTgb4zFqCfFDhmUO3w==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.34.tgz", + "integrity": "sha512-06kEJO3iyohmAqF4iIbOxOfWLFSIpLDJ1L1oEHRtouMrH2Ll1wrUjsoQT1gXgBOv7rifl25qx/Avx5zKqvuORw==", "cpu": [ "arm64" ], @@ -1192,9 +1193,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.38.tgz", - "integrity": "sha512-PhAUhWRbg718Uc+a6RXqoGN8fGYD+Rj5FWQPQ3rbmgZitPRzlT/WrQaWj0BenRERUjLshPuxSm1GJUB4Kyc/7Q==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.34.tgz", + "integrity": "sha512-QLL8pS4q2TTyQbClEXxqXtQGPr4lk+pwc8hPMUL7iw7HGDOvs1WCLMT1ZSDPPcxSrTnR/dURX5za1NMA8uF/fw==", "cpu": [ "x64" ], @@ -2488,6 +2489,13 @@ "node": ">=0.4.0" } }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", @@ -2752,6 +2760,13 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/kerberos": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@types/kerberos/-/kerberos-1.1.2.tgz", @@ -5115,6 +5130,23 @@ "node": ">=12.17" } }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array-differ": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-1.0.0.tgz", @@ -5133,6 +5165,29 @@ "node": ">=0.10.0" } }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array-initial": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/array-initial/-/array-initial-1.1.0.tgz", @@ -5217,6 +5272,88 @@ "node": ">=0.10.0" } }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/arrify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", @@ -5337,6 +5474,16 @@ "integrity": "sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==", "dev": true }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/async-settle": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-settle/-/async-settle-1.0.0.tgz", @@ -5382,10 +5529,14 @@ } }, "node_modules/available-typed-arrays": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", - "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, "engines": { "node": ">= 0.4" }, @@ -5956,16 +6107,16 @@ } }, "node_modules/call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "dev": true, + "license": "MIT", "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" }, "engines": { "node": ">= 0.4" @@ -7115,6 +7266,60 @@ "node": ">= 14" } }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/debounce": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.1.0.tgz", @@ -7358,15 +7563,21 @@ } }, "node_modules/define-properties": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", - "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dev": true, + "license": "MIT", "dependencies": { - "object-keys": "^1.0.12" + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/define-property": { @@ -7503,6 +7714,19 @@ "randombytes": "^2.0.0" } }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -7886,6 +8110,75 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -7936,6 +8229,37 @@ "node": ">= 0.4" } }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es5-ext": { "version": "0.10.64", "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", @@ -8111,56 +8435,184 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/eslint-plugin-header": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-header/-/eslint-plugin-header-3.1.1.tgz", - "integrity": "sha512-9vlKxuJ4qf793CmeeSrZUvVClw6amtpghq3CuWcB5cUNnWHQhgcqy5eF8oVKFk1G3Y/CbchGfEaw3wiIJaNmVg==", - "dev": true, - "peerDependencies": { - "eslint": ">=7.7.0" - } - }, - "node_modules/eslint-plugin-jsdoc": { - "version": "50.3.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-50.3.1.tgz", - "integrity": "sha512-SY9oUuTMr6aWoJggUS40LtMjsRzJPB5ZT7F432xZIHK3EfHF+8i48GbUBpwanrtlL9l1gILNTHK9o8gEhYLcKA==", + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", "dev": true, + "license": "MIT", "dependencies": { - "@es-joy/jsdoccomment": "~0.48.0", - "are-docs-informative": "^0.0.2", - "comment-parser": "1.4.1", - "debug": "^4.3.6", - "escape-string-regexp": "^4.0.0", - "espree": "^10.1.0", - "esquery": "^1.6.0", - "parse-imports": "^2.1.1", - "semver": "^7.6.3", - "spdx-expression-parse": "^4.0.0", - "synckit": "^0.9.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" } }, - "node_modules/eslint-plugin-jsdoc/node_modules/spdx-expression-parse": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", - "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, + "license": "MIT", "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" + "ms": "^2.1.1" } }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "node_modules/eslint-import-resolver-node/node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-header": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-header/-/eslint-plugin-header-3.1.1.tgz", + "integrity": "sha512-9vlKxuJ4qf793CmeeSrZUvVClw6amtpghq3CuWcB5cUNnWHQhgcqy5eF8oVKFk1G3Y/CbchGfEaw3wiIJaNmVg==", + "dev": true, + "peerDependencies": { + "eslint": ">=7.7.0" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-jsdoc": { + "version": "50.3.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-50.3.1.tgz", + "integrity": "sha512-SY9oUuTMr6aWoJggUS40LtMjsRzJPB5ZT7F432xZIHK3EfHF+8i48GbUBpwanrtlL9l1gILNTHK9o8gEhYLcKA==", + "dev": true, + "dependencies": { + "@es-joy/jsdoccomment": "~0.48.0", + "are-docs-informative": "^0.0.2", + "comment-parser": "1.4.1", + "debug": "^4.3.6", + "escape-string-regexp": "^4.0.0", + "espree": "^10.1.0", + "esquery": "^1.6.0", + "parse-imports": "^2.1.1", + "semver": "^7.6.3", + "spdx-expression-parse": "^4.0.0", + "synckit": "^0.9.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-jsdoc/node_modules/spdx-expression-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "dev": true, + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -9308,12 +9760,19 @@ } }, "node_modules/for-each": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", - "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", "dev": true, + "license": "MIT", "dependencies": { - "is-callable": "^1.1.3" + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/for-in": { @@ -9501,6 +9960,37 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/geckodriver": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/geckodriver/-/geckodriver-6.1.0.tgz", @@ -9536,6 +10026,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -9638,6 +10138,24 @@ "once": "^1.3.1" } }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-uri": { "version": "6.0.5", "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", @@ -10142,13 +10660,14 @@ } }, "node_modules/globalthis": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.1.tgz", - "integrity": "sha512-mJPRTc/P39NH/iNG4mXa9aIhNymaQikTrnspeCa2ZuJ+mH2QN/rXwtX3XwKrHqWgUQFbNZKtHM105aHzJalElw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", "dev": true, - "optional": true, + "license": "MIT", "dependencies": { - "define-properties": "^1.1.3" + "define-properties": "^1.2.1", + "gopd": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -11500,18 +12019,6 @@ "xtend": "~4.0.1" } }, - "node_modules/has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.1" - }, - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/has-ansi": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", @@ -11533,6 +12040,19 @@ "node": ">=0.10.0" } }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -11555,6 +12075,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -12063,6 +12599,21 @@ "node": ">= 0.8.0" } }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/interpret": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", @@ -12154,35 +12705,59 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", "dev": true, + "license": "MIT", "dependencies": { - "binary-extensions": "^2.0.0" + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-buffer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", - "dev": true - }, - "node_modules/is-callable": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.2.tgz", - "integrity": "sha512-dnMqspv5nU3LoewK2N/y7KLtxtakvTuaCsU9FU50/QDmdbHNy/4/JuRtMHqRU22o3q+W89YQndQEeCVwK+3qrA==", + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, "engines": { "node": ">= 0.4" }, @@ -12190,11 +12765,59 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-ci": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-1.2.1.tgz", - "integrity": "sha512-s6tfsaQaQi3JNciBH6shVqEDvhGut0SUXr31ag8Pd8BBbVVlcGfWhpPmEOoM6RJ5TFhbypvf5yyRw/VXW1IiWg==", - "dev": true, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-ci": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-1.2.1.tgz", + "integrity": "sha512-s6tfsaQaQi3JNciBH6shVqEDvhGut0SUXr31ag8Pd8BBbVVlcGfWhpPmEOoM6RJ5TFhbypvf5yyRw/VXW1IiWg==", + "dev": true, "dependencies": { "ci-info": "^1.5.0" }, @@ -12203,12 +12826,16 @@ } }, "node_modules/is-core-module": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", - "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "dev": true, + "license": "MIT", "dependencies": { - "has": "^1.0.3" + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -12236,6 +12863,41 @@ "node": ">=0.10.0" } }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-deflate": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-deflate/-/is-deflate-1.0.0.tgz", @@ -12312,6 +12974,22 @@ "node": ">=0.10.0" } }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -12322,10 +13000,18 @@ } }, "node_modules/is-generator-function": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.9.tgz", - "integrity": "sha512-ZJ34p1uvIfptHCN7sFTjGibB9/oBg17sHqzDLfuwhvmN/qLVvIQXRQ8licZQ35WJ8KuEQt/etnnzQFI9C9Ue/A==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, "engines": { "node": ">= 0.4" }, @@ -12383,6 +13069,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-negated-glob": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-negated-glob/-/is-negated-glob-1.0.0.tgz", @@ -12392,6 +13091,19 @@ "node": ">=0.10.0" } }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -12401,6 +13113,23 @@ "node": ">=0.12.0" } }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-plain-obj": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", @@ -12425,6 +13154,25 @@ "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", "dev": true }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-relative": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz", @@ -12437,6 +13185,35 @@ "node": ">=0.10.0" } }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-stream": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", @@ -12446,13 +13223,49 @@ "node": ">=0.10.0" } }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-typed-array": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", - "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "dev": true, + "license": "MIT", "dependencies": { - "which-typed-array": "^1.1.11" + "which-typed-array": "^1.1.16" }, "engines": { "node": ">= 0.4" @@ -12506,6 +13319,52 @@ "node": ">=0.10.0" } }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-windows": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", @@ -14394,6 +15253,35 @@ "node": "^16 || ^18 || >= 20" } }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/node-fetch": { "version": "2.6.8", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.8.tgz", @@ -14912,14 +15800,17 @@ } }, "node_modules/object.assign": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz", - "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.0", - "define-properties": "^1.1.3", - "has-symbols": "^1.0.1", + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", "object-keys": "^1.1.1" }, "engines": { @@ -14944,6 +15835,56 @@ "node": ">=0.10.0" } }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/object.map": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/object.map/-/object.map-1.0.1.tgz", @@ -14982,6 +15923,25 @@ "node": ">=0.10.0" } }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -15236,6 +16196,24 @@ "os-tmpdir": "^1.0.0" } }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/p-all": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-all/-/p-all-1.0.0.tgz", @@ -15796,6 +16774,16 @@ "node": ">=0.10.0" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "7.0.39", "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz", @@ -16413,6 +17401,29 @@ "node": ">= 0.10" } }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/regex-not": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", @@ -16426,6 +17437,27 @@ "node": ">=0.10.0" } }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/remove-bom-buffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/remove-bom-buffer/-/remove-bom-buffer-3.0.0.tgz", @@ -16906,14 +17938,65 @@ "dev": true, "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=18.0.0" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-array-concat/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-regex": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", @@ -16923,6 +18006,24 @@ "ret": "~0.1.10" } }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safe-regex2": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz", @@ -17156,6 +18257,37 @@ "node": ">= 0.4" } }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/set-value": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", @@ -17998,6 +19130,20 @@ "ieee754": "^1.2.1" } }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/stream-combiner": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz", @@ -18150,6 +19296,65 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -18176,6 +19381,16 @@ "node": ">=8" } }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/strip-bom-string": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", @@ -18843,6 +20058,32 @@ "code-block-writer": "^13.0.3" } }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, "node_modules/tsec": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/tsec/-/tsec-0.2.7.tgz", @@ -19017,6 +20258,84 @@ "url": "https://opencollective.com/express" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", @@ -19086,6 +20405,25 @@ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "dev": true }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/unc-path-regex": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", @@ -19899,6 +21237,80 @@ "node": ">= 8" } }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/which-module": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", @@ -19906,16 +21318,19 @@ "dev": true }, "node_modules/which-typed-array": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.11.tgz", - "integrity": "sha512-qe9UWWpkeG5yzZ0tNYxDmd7vo58HDBc39mZ0xWWpolAGADdFOzkfamWLDxkOWcvHQKVmdTyQdLD4NOfjLWTKew==", + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", "dev": true, + "license": "MIT", "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0" + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" diff --git a/package.json b/package.json index 611244f787fdd..9c26d2924adb0 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ }, "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.49", - "@github/copilot": "^1.0.38", + "@github/copilot": "1.0.34", "@github/copilot-sdk": "^0.2.2", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", @@ -198,6 +198,7 @@ "eslint": "^9.36.0", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", + "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsdoc": "^50.3.1", "event-stream": "3.3.4", "fancy-log": "^1.3.3", diff --git a/remote/package-lock.json b/remote/package-lock.json index f660056081444..c1055bdfd7119 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.49", - "@github/copilot": "^1.0.38", + "@github/copilot": "1.0.34", "@github/copilot-sdk": "^0.2.2", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", @@ -81,26 +81,26 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.38.tgz", - "integrity": "sha512-GjtKCiFczeKuECOuxkBkJYb8estSnhxgh4iQ9BTkWg4y3EWYl2VaMCXCu9KkVPf/fwy/URt1l8Rf4M4tZxVZAA==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.34.tgz", + "integrity": "sha512-jFYulj1v00b3j43Er9+WwhZ/XldGq7+gti2s2pRhrdPwYEd1PMvscDZwRa/1iUBz/XQ5HUGac1tD8P7+VUpWjg==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.38", - "@github/copilot-darwin-x64": "1.0.38", - "@github/copilot-linux-arm64": "1.0.38", - "@github/copilot-linux-x64": "1.0.38", - "@github/copilot-win32-arm64": "1.0.38", - "@github/copilot-win32-x64": "1.0.38" + "@github/copilot-darwin-arm64": "1.0.34", + "@github/copilot-darwin-x64": "1.0.34", + "@github/copilot-linux-arm64": "1.0.34", + "@github/copilot-linux-x64": "1.0.34", + "@github/copilot-win32-arm64": "1.0.34", + "@github/copilot-win32-x64": "1.0.34" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.38.tgz", - "integrity": "sha512-JyzyQ/VUC30QBOnOoqBbfAlMbIycKVqIOepeTdArNk+oER8qfQ9LqQPxA6FDqCQl3GAMclzqZGL9jK7I2WldhA==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.34.tgz", + "integrity": "sha512-g94EhSLd3a6fckZ6xb/zP2DZJZEx7kONWdOoDiHXUtSqc4RiZ7OBq1EwT4WrPY1lsmy9sioJIcZSGzJd0C1M7Q==", "cpu": [ "arm64" ], @@ -114,9 +114,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.38.tgz", - "integrity": "sha512-2Wv/4KPY2XC6JRGvJzavrk/RBmbH3Z5pNZZslL0BW2+AeZsoYqmVrA/1pxUs+KSVaGDC420dqS7uZ6u/mg23oQ==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.34.tgz", + "integrity": "sha512-tIgFEZV0ohCF/VgTODJWre3xURsvEd+6IPN/HPKWxG6AXtJOxzjlr5kLYYdPHdNlHNmSxGQw8fWsN2FZ4nyDdw==", "cpu": [ "x64" ], @@ -130,9 +130,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.38.tgz", - "integrity": "sha512-s+rNuvL3pKkZ6orZZoKcsbNDlu79f6/EBj5ovo2pJ6iBI3YMNwUM8AZq9pcFUpZCaLJ6E7GGZoujRMbpjKP/wQ==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.34.tgz", + "integrity": "sha512-feqjEetrlqBUhYskIsPmwACQOWO99cvRpKwIFl3OlEjWoj+//HA7yXh49UIe0gD8wQUI8hy05uVz3K2/xti2nQ==", "cpu": [ "arm64" ], @@ -146,9 +146,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.38.tgz", - "integrity": "sha512-8aAXJ0Qv+4naW4FcsqQNzgGykaiYe5q7ZO55ZuUMQ92ZY+Kae5kTttwiZ325T9CdeNHVT9f+aMx8gAGVWxfvFg==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.34.tgz", + "integrity": "sha512-3l0rZZqmceklHizJaaO+Iy2PsAZpVZS9Mn9VYnVcY/8Yzt4Y2hmXSFcKVfc4l+JlhFsPs7trhMdIkfwkjaKPLg==", "cpu": [ "x64" ], @@ -185,9 +185,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.38.tgz", - "integrity": "sha512-M7Da1h25IsnYyw9LBCatxgQUsu+C5+xJsHMZeR8dnxRF/kt75Ksqk1+pWp8oBk1BqK9ahTgb4zFqCfFDhmUO3w==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.34.tgz", + "integrity": "sha512-06kEJO3iyohmAqF4iIbOxOfWLFSIpLDJ1L1oEHRtouMrH2Ll1wrUjsoQT1gXgBOv7rifl25qx/Avx5zKqvuORw==", "cpu": [ "arm64" ], @@ -201,9 +201,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.38.tgz", - "integrity": "sha512-PhAUhWRbg718Uc+a6RXqoGN8fGYD+Rj5FWQPQ3rbmgZitPRzlT/WrQaWj0BenRERUjLshPuxSm1GJUB4Kyc/7Q==", + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.34.tgz", + "integrity": "sha512-QLL8pS4q2TTyQbClEXxqXtQGPr4lk+pwc8hPMUL7iw7HGDOvs1WCLMT1ZSDPPcxSrTnR/dURX5za1NMA8uF/fw==", "cpu": [ "x64" ], diff --git a/remote/package.json b/remote/package.json index 47ba54f1df811..5b971c7551fe2 100644 --- a/remote/package.json +++ b/remote/package.json @@ -4,7 +4,7 @@ "private": true, "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.49", - "@github/copilot": "^1.0.38", + "@github/copilot": "1.0.34", "@github/copilot-sdk": "^0.2.2", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", diff --git a/resources/linux/debian/postinst.template b/resources/linux/debian/postinst.template index fa21fc470f1a0..fbfd923f27cbc 100755 --- a/resources/linux/debian/postinst.template +++ b/resources/linux/debian/postinst.template @@ -67,18 +67,18 @@ if [ "@@NAME@@" != "code-oss" ]; then # Determine whether to write the Microsoft repository source list WRITE_SOURCE='no' + WRITE_KEY='no' if [ "$RET" = 'false' ]; then # The user specified in debconf not to add the Microsoft repository WRITE_SOURCE='no' elif [ -f "$CODE_SOURCE_PART" ]; then # The user is not on the new DEB822 format WRITE_SOURCE='yes' + WRITE_KEY='yes' elif [ -f "$CODE_SOURCE_PART_DEB822" ]; then # The user is on the new DEB822 format, but refresh the file contents WRITE_SOURCE='yes' - elif has_existing_repo_source; then - # Another source list file already maps to this repository - WRITE_SOURCE='no' + WRITE_KEY='yes' elif [ -f /etc/rpi-issue ]; then # Do not write on Raspberry Pi OS # https://github.com/microsoft/vscode/issues/118825 @@ -92,6 +92,7 @@ if [ "@@NAME@@" != "code-oss" ]; then # By default, write sources in a non-interactive terminal # to match old behavior. WRITE_SOURCE='yes' + WRITE_KEY='yes' elif [ -e '/usr/share/debconf/confmodule' ]; then # Ask the user whether to actually write the source list db_input high @@NAME@@/add-microsoft-repo || true @@ -100,16 +101,25 @@ if [ "@@NAME@@" != "code-oss" ]; then db_get @@NAME@@/add-microsoft-repo if [ "$RET" = false ]; then WRITE_SOURCE='no' + WRITE_KEY='no' else WRITE_SOURCE='yes' + WRITE_KEY='yes' fi else # The terminal is interactive but there is no debconf. # Write sources to match old behavior. WRITE_SOURCE='yes' + WRITE_KEY='yes' fi fi + if has_existing_repo_source; then + # Another source list file already maps to this repository. + # Keep key writing behavior, but do not write our own source entry. + WRITE_SOURCE='no' + fi + if [ "$WRITE_SOURCE" != 'no' ]; then # Write repository in deb822 format with Signed-By. echo "### THIS FILE IS AUTOMATICALLY CONFIGURED ### @@ -125,7 +135,9 @@ EOF if [ -f "$CODE_SOURCE_PART" ]; then rm -f "$CODE_SOURCE_PART" fi + fi + if [ "$WRITE_KEY" != 'no' ]; then # Sourced from https://packages.microsoft.com/keys/microsoft.asc if [ ! -f $CODE_TRUSTED_PART ]; then echo "-----BEGIN PGP PUBLIC KEY BLOCK----- diff --git a/src/vs/base/common/observableInternal/index.ts b/src/vs/base/common/observableInternal/index.ts index 805f7d75e7d6c..a924e98352669 100644 --- a/src/vs/base/common/observableInternal/index.ts +++ b/src/vs/base/common/observableInternal/index.ts @@ -6,7 +6,7 @@ // This is a facade for the observable implementation. Only import from here! export { observableValueOpts } from './observables/observableValueOpts.js'; -export { autorun, autorunDelta, autorunHandleChanges, autorunOpts, autorunWithStore, autorunWithStoreHandleChanges, autorunIterableDelta, autorunSelfDisposable } from './reactions/autorun.js'; +export { autorun, autorunDelta, autorunHandleChanges, autorunOpts, autorunWithStore, autorunWithStoreHandleChanges, autorunIterableDelta, autorunPerKeyedItem, autorunSelfDisposable } from './reactions/autorun.js'; export { type IObservable, type IObservableWithChange, type IObserver, type IReader, type ISettable, type IReaderWithStore, type ISettableObservable, type ITransaction } from './base.js'; export { disposableObservableValue } from './observables/observableValue.js'; export { derived, derivedDisposable, derivedHandleChanges, derivedOpts, derivedWithSetter, derivedWithStore } from './observables/derived.js'; diff --git a/src/vs/base/common/observableInternal/reactions/autorun.ts b/src/vs/base/common/observableInternal/reactions/autorun.ts index de97bb6fb2e21..dad3cbdd20329 100644 --- a/src/vs/base/common/observableInternal/reactions/autorun.ts +++ b/src/vs/base/common/observableInternal/reactions/autorun.ts @@ -3,12 +3,14 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IReaderWithStore, IReader, IObservable } from '../base.js'; +import { IReaderWithStore, IReader, IObservable, ISettableObservable } from '../base.js'; import { IChangeTracker } from '../changeTracker.js'; import { DisposableStore, IDisposable, toDisposable } from '../commonFacade/deps.js'; import { DebugNameData, IDebugNameData } from '../debugName.js'; import { AutorunObserver } from './autorunImpl.js'; import { DebugLocation } from '../debugLocation.js'; +import { observableValue } from '../observables/observableValue.js'; +import { transaction } from '../transaction.js'; /** * Runs immediately and whenever a transaction ends and an observed observable changed. @@ -155,6 +157,76 @@ export function autorunIterableDelta( }); } +/** + * For each key-stable item in {@link items}, runs {@link setup} once when the + * key is first observed and disposes the per-key {@link DisposableStore} when + * the key is no longer present in the array (or when the returned disposable + * is disposed). + * + * The {@link IObservable} handed to {@link setup} fires whenever the array + * still contains an item with the same key but the item value itself has + * changed (e.g. because the upstream state is immutable and produced a new + * object with the same id). All per-key value updates triggered by a single + * change to {@link items} are batched into one transaction, so dependent + * autoruns observe a consistent snapshot. + * + * Per-key state should be stored in closures or in disposables registered + * against the per-key {@link DisposableStore}. {@link setup} should not call + * `.read()` on the outer {@link items} observable from its body (use the + * provided per-key value observable, or create inner autoruns). + */ +export function autorunPerKeyedItem( + items: IObservable, + keyFn: (input: TIn) => TKey, + setup: (key: TKey, value: IObservable, store: DisposableStore) => void, + debugLocation = DebugLocation.ofCaller() +): IDisposable { + interface ICell { + readonly value: ISettableObservable; + readonly store: DisposableStore; + } + const cells = new Map(); + const ar = autorunOpts({ debugReferenceFn: setup }, reader => { + const arr = items.read(reader); + const seen = new Set(); + const additions: { key: TKey; cell: ICell }[] = []; + transaction(tx => { + for (const item of arr) { + const key = keyFn(item); + seen.add(key); + const existing = cells.get(key); + if (existing) { + existing.value.set(item, tx); + } else { + const store = new DisposableStore(); + const value = observableValue('keyedItem', item); + const cell: ICell = { value, store }; + cells.set(key, cell); + additions.push({ key, cell }); + } + } + for (const [k, cell] of cells) { + if (!seen.has(k)) { + cell.store.dispose(); + cells.delete(k); + } + } + }); + // Setup runs after the transaction so per-key autoruns observe the + // final cell values on their first read. + for (const { key, cell } of additions) { + setup(key, cell.value, cell.store); + } + }, debugLocation); + return toDisposable(() => { + ar.dispose(); + for (const cell of cells.values()) { + cell.store.dispose(); + } + cells.clear(); + }); +} + export interface IReaderWithDispose extends IReaderWithStore, IDisposable { } /** diff --git a/src/vs/base/test/common/observables/observable.test.ts b/src/vs/base/test/common/observables/observable.test.ts index c1814dfe7b1ff..3caaca9fc6b90 100644 --- a/src/vs/base/test/common/observables/observable.test.ts +++ b/src/vs/base/test/common/observables/observable.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { setUnexpectedErrorHandler } from '../../../common/errors.js'; import { Emitter, Event } from '../../../common/event.js'; import { DisposableStore, toDisposable } from '../../../common/lifecycle.js'; -import { IDerivedReader, IObservableWithChange, autorun, autorunHandleChanges, autorunWithStoreHandleChanges, derived, derivedDisposable, IObservable, IObserver, ISettableObservable, ITransaction, keepObserved, observableFromEvent, observableSignal, observableValue, recordChanges, transaction, waitForState, derivedHandleChanges, runOnChange, DebugLocation } from '../../../common/observable.js'; +import { IDerivedReader, IObservableWithChange, autorun, autorunHandleChanges, autorunPerKeyedItem, autorunWithStoreHandleChanges, derived, derivedDisposable, IObservable, IObserver, ISettableObservable, ITransaction, keepObserved, observableFromEvent, observableSignal, observableValue, recordChanges, transaction, waitForState, derivedHandleChanges, runOnChange, DebugLocation } from '../../../common/observable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../utils.js'; // eslint-disable-next-line local/code-no-deep-import-of-internal import { observableReducer } from '../../../common/observableInternal/experimental/reducer.js'; @@ -1738,6 +1738,156 @@ suite('observables', () => { disp.dispose(); }); + + suite('autorunPerKeyedItem', () => { + test('runs setup once per key, fires per-key observable on in-place value change, disposes on removal', () => { + const log = new Log(); + const items = observableValue('items', []); + + const d = ds.add(autorunPerKeyedItem( + items, + it => it.id, + (key, value, store) => { + log.log(`setup(${key})`); + store.add(toDisposable(() => log.log(`dispose(${key})`))); + store.add(autorun(reader => { + const v = value.read(reader); + log.log(`autorun(${key}): v=${v.v}`); + })); + } + )); + assert.deepStrictEqual(log.getAndClearEntries(), []); + + items.set([{ id: 'a', v: 1 }, { id: 'b', v: 1 }], undefined); + assert.deepStrictEqual(log.getAndClearEntries(), [ + 'setup(a)', + 'autorun(a): v=1', + 'setup(b)', + 'autorun(b): v=1', + ]); + + // In-place value change on `a` (same key, new immutable object) → its + // per-key observable fires. `b` is also a new object literal here, so + // its observable fires too: identity comparison, not deep-equality. + items.set([{ id: 'a', v: 2 }, { id: 'b', v: 1 }], undefined); + assert.deepStrictEqual(log.getAndClearEntries(), [ + 'autorun(a): v=2', + 'autorun(b): v=1', + ]); + + // Remove `a`: its store is disposed; `b` survives (its observable + // also fires because the new array contains a fresh object literal + // for `b`). + items.set([{ id: 'b', v: 1 }], undefined); + assert.deepStrictEqual(log.getAndClearEntries(), [ + 'dispose(a)', + 'autorun(b): v=1', + ]); + + // Add `a` back: setup runs again from scratch. `b` fires once more + // because the new array literal contains a fresh `b` object. + items.set([{ id: 'b', v: 1 }, { id: 'a', v: 9 }], undefined); + assert.deepStrictEqual(log.getAndClearEntries(), [ + 'autorun(b): v=1', + 'setup(a)', + 'autorun(a): v=9', + ]); + + d.dispose(); + // Disposing the autorun disposes all remaining per-key stores. + assert.deepStrictEqual(log.getAndClearEntries().sort(), [ + 'dispose(a)', + 'dispose(b)', + ]); + }); + + test('batches per-key value updates atomically across one items change', () => { + const log = new Log(); + const items = observableValue('items', [ + { id: 'a', v: 0 }, + { id: 'b', v: 0 }, + ]); + + ds.add(autorunPerKeyedItem( + items, + it => it.id, + (key, value, store) => { + store.add(autorun(reader => { + log.log(`${key}=${value.read(reader).v}`); + })); + } + )); + assert.deepStrictEqual(log.getAndClearEntries(), ['a=0', 'b=0']); + + // Single upstream change updates both keys; per-key autoruns each fire + // once with the post-change values. + items.set([{ id: 'a', v: 1 }, { id: 'b', v: 2 }], undefined); + assert.deepStrictEqual(log.getAndClearEntries(), ['a=1', 'b=2']); + }); + + test('does not fire per-key observable when same item identity is reused', () => { + const log = new Log(); + const a = { id: 'a', v: 1 }; + const items = observableValue('items', [a]); + + ds.add(autorunPerKeyedItem( + items, + it => it.id, + (_key, value, store) => { + store.add(autorun(reader => log.log(`v=${value.read(reader).v}`))); + } + )); + assert.deepStrictEqual(log.getAndClearEntries(), ['v=1']); + + // Same array shape, same item identity → no value change, no autorun fire. + items.set([a], undefined); + assert.deepStrictEqual(log.getAndClearEntries(), []); + }); + + test('per-key setup fires when items derived through observableFromEvent chain updates', () => { + // Mirrors how agentHostSessionHandler uses observableFromEvent → + // derived(activeTurn) → derived(responseParts) → autorunPerKeyedItem. + // Verifies that incremental upstream Event fires propagate through + // the chain and the per-key setup observes the new items. + const log = new Log(); + interface Part { readonly id: string; readonly content: string } + interface State { readonly active?: { readonly id: string; readonly parts: readonly Part[] } } + + let current: State | undefined = undefined; + const onChange = ds.add(new Emitter()); + const fakeSub = { value: undefined as State | undefined, onDidChange: onChange.event }; + const sessionState$ = observableFromEvent(undefined, fakeSub.onDidChange, () => fakeSub.value); + const fire = (s: State) => { current = s; fakeSub.value = s; onChange.fire(s); }; + + const turn$ = derived(reader => sessionState$.read(reader)?.active); + const parts$ = derived(reader => turn$.read(reader)?.parts ?? []); + + ds.add(autorunPerKeyedItem( + parts$, + p => p.id, + (key, p$, store) => { + log.log(`setup(${key})`); + store.add(autorun(reader => log.log(`${key}=${p$.read(reader).content.length}`))); + } + )); + assert.deepStrictEqual(log.getAndClearEntries(), []); + + // First state with one part — same shape as a turn starting with content. + fire({ active: { id: 't1', parts: [{ id: 'p1', content: 'hello' }] } }); + assert.deepStrictEqual(log.getAndClearEntries(), ['setup(p1)', 'p1=5']); + + // Append more content to p1. + fire({ active: { id: 't1', parts: [{ id: 'p1', content: 'hello world' }] } }); + assert.deepStrictEqual(log.getAndClearEntries(), ['p1=11']); + + // Add a new part p2. p1 also fires because the new array literal + // allocates a fresh object for it (identity differs even though + // content is the same). + fire({ active: { id: 't1', parts: [{ id: 'p1', content: 'hello world' }, { id: 'p2', content: 'reasoning' }] } }); + assert.deepStrictEqual(log.getAndClearEntries(), ['p1=11', 'setup(p2)', 'p2=9']); + void current; + }); + }); }); export class LoggingObserver implements IObserver { diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index d3aeb330b7f81..5ab2847f4451b 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -12,10 +12,10 @@ import { createDecorator } from '../../instantiation/common/instantiation.js'; import type { ISyncedCustomization } from './agentPluginManager.js'; import type { IAgentSubscription } from './state/agentSubscription.js'; import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from './state/protocol/commands.js'; -import { ProtectedResourceMetadata, type ConfigSchema, type FileEdit, type ModelSelection, type SessionActiveClient, type ToolDefinition } from './state/protocol/state.js'; +import { ProtectedResourceMetadata, type ConfigSchema, type FileEdit, type ModelSelection, type SessionActiveClient, type ToolCallPendingConfirmationState, type ToolDefinition } from './state/protocol/state.js'; import type { ActionEnvelope, INotification, IRootConfigChangedAction, SessionAction, TerminalAction } from './state/sessionActions.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult, IStateSnapshot } from './state/sessionProtocol.js'; -import { AttachmentType, ComponentToState, SessionInputResponseKind, SessionStatus, StateComponents, type CustomizationRef, type PendingMessage, type RootState, type SessionCustomization, type SessionInputAnswer, type SessionInputRequest, type SessionMeta, type ToolCallResult, type ToolResultContent, type PolicyState, type StringOrMarkdown } from './state/sessionState.js'; +import { AttachmentType, ComponentToState, SessionInputResponseKind, SessionStatus, StateComponents, type CustomizationRef, type PendingMessage, type RootState, type SessionCustomization, type SessionInputAnswer, type SessionMeta, type ToolCallResult, type Turn, type PolicyState } from './state/sessionState.js'; // IPC contract between the renderer and the agent host utility process. // Defines all serializable event types, the IAgent provider interface, @@ -209,201 +209,90 @@ export interface IAgentModelInfo { readonly policyState?: PolicyState; } -// ---- Progress events (discriminated union by `type`) ------------------------ +// ---- Agent signals (sent via IAgent.onDidSessionProgress) ------------------- -interface IAgentProgressEventBase { - readonly session: URI; -} - -/** Streaming text delta from the assistant (`assistant.message_delta`). */ -export interface IAgentDeltaEvent extends IAgentProgressEventBase { - readonly type: 'delta'; - readonly messageId: string; - readonly content: string; - readonly parentToolCallId?: string; -} - -/** A complete assistant message (`assistant.message`), used for history reconstruction. */ -export interface IAgentMessageEvent extends IAgentProgressEventBase { - readonly type: 'message'; - readonly role: 'user' | 'assistant'; - readonly messageId: string; - readonly content: string; - readonly toolRequests?: readonly { - readonly toolCallId: string; - readonly name: string; - /** Serialized JSON of arguments, if available. */ - readonly arguments?: string; - readonly type?: 'function' | 'custom'; - }[]; - readonly reasoningOpaque?: string; - readonly reasoningText?: string; - readonly encryptedContent?: string; - readonly parentToolCallId?: string; -} - -/** The session has finished processing and is waiting for input (`session.idle`). */ -export interface IAgentIdleEvent extends IAgentProgressEventBase { - readonly type: 'idle'; -} - -/** A tool has started executing (`tool.execution_start`). */ -export interface IAgentToolStartEvent extends IAgentProgressEventBase { - readonly type: 'tool_start'; - readonly toolCallId: string; - readonly toolName: string; - /** Human-readable display name for this tool. */ - readonly displayName: string; - /** Message describing the tool invocation in progress (e.g., "Running `echo hello`"). */ - readonly invocationMessage: StringOrMarkdown; - /** A representative input string for display in the UI (e.g., the shell command). */ - readonly toolInput?: string; - /** Hint for the renderer about how to display this tool (e.g., 'terminal' for shell commands, 'subagent' for subagent-spawning tools). */ - readonly toolKind?: 'terminal' | 'subagent'; - /** Language identifier for syntax highlighting (e.g., 'shellscript', 'powershell'). Used with toolKind 'terminal'. */ - readonly language?: string; - /** Serialized JSON of the tool arguments, if available. */ - readonly toolArguments?: string; - /** - * For `toolKind === 'subagent'`, the internal name of the agent being - * spawned (e.g. 'explore'). Adapters are responsible for extracting this - * from their SDK-specific tool argument shape. - */ - readonly subagentAgentName?: string; - /** - * For `toolKind === 'subagent'`, a human-readable description of the - * subagent's task. Adapters are responsible for extracting this from - * their SDK-specific tool argument shape. - */ - readonly subagentDescription?: string; - readonly mcpServerName?: string; - readonly mcpToolName?: string; - readonly parentToolCallId?: string; - /** - * If set, this tool is provided by a client and the identified client - * is responsible for executing it. Maps to `toolClientId` in the - * protocol `session/toolCallStart` action. - */ - readonly toolClientId?: string; -} +/** + * A signal emitted by an agent during session execution. + * + * Most signals carry a protocol {@link SessionAction} directly via the + * `kind: 'action'` shape, eliminating a parallel event ontology. A small + * number of cases that have no clean protocol action (permission + * auto-approval, subagent session creation, steering message + * acknowledgment) remain as discriminated non-action signals so the host + * can perform side effects before — or instead of — dispatching an action. + */ +export type AgentSignal = + | IAgentActionSignal + | IAgentToolPendingConfirmationSignal + | IAgentSubagentStartedSignal + | IAgentSteeringConsumedSignal; -/** A tool has finished executing (`tool.execution_complete`). */ -export interface IAgentToolCompleteEvent extends IAgentProgressEventBase { - readonly type: 'tool_complete'; - readonly toolCallId: string; - /** Tool execution result, matching the protocol {@link ToolCallResult} shape. */ - readonly result: ToolCallResult; - readonly isUserRequested?: boolean; - /** Serialized JSON of tool-specific telemetry data. */ - readonly toolTelemetry?: string; +/** + * Carries a protocol {@link SessionAction} produced by an agent. The host + * dispatches the action through the state manager after routing via + * {@link IAgentActionSignal.parentToolCallId} (if set). + * + * Agents are responsible for populating `session` and any `turnId` / + * `partId` fields on the action. + */ +export interface IAgentActionSignal { + readonly kind: 'action'; + /** Top-level session URI. For inner subagent events this is the parent session — see {@link parentToolCallId}. */ + readonly session: URI; + /** Protocol action to dispatch. */ + readonly action: SessionAction; + /** If set, route the action to the subagent session belonging to this tool call. */ readonly parentToolCallId?: string; } -/** The session title has been updated. */ -export interface IAgentTitleChangedEvent extends IAgentProgressEventBase { - readonly type: 'title_changed'; - readonly title: string; -} - -/** An error occurred during session processing. */ -export interface IAgentErrorEvent extends IAgentProgressEventBase { - readonly type: 'error'; - readonly errorType: string; - readonly message: string; - readonly stack?: string; -} - -/** Token usage information for a request. */ -export interface IAgentUsageEvent extends IAgentProgressEventBase { - readonly type: 'usage'; - readonly inputTokens?: number; - readonly outputTokens?: number; - readonly model?: string; - readonly cacheReadTokens?: number; -} - /** - * A running tool requires re-confirmation (e.g. a mid-execution permission check). - * Maps to `SessionToolCallReady` without `confirmed` to transition Running → PendingConfirmation. + * A tool has finished collecting parameters and needs the host to decide + * whether it should run (or, mid-execution, re-confirm). The host applies + * auto-approval logic over {@link permissionKind} / {@link permissionPath} + * (see `SessionPermissionManager.getAutoApproval`) and then dispatches the + * appropriate `SessionToolCallReady` action — with confirmation options + * baked in when the user must approve, or with `confirmed: NotNeeded` when + * the host auto-approved. + * + * Kept as a non-action signal because the host owns this approval policy; + * the agent only describes the tool call and the kind of permission being + * requested. The {@link state} field carries the protocol-shaped tool-call + * state and is dispatched verbatim into the action. */ -export interface IAgentToolReadyEvent extends IAgentProgressEventBase { - readonly type: 'tool_ready'; - readonly toolCallId: string; - /** Message describing what confirmation is needed. */ - readonly invocationMessage: StringOrMarkdown; - /** Raw tool input to display. */ - readonly toolInput?: string; - /** Short title for the confirmation prompt. */ - readonly confirmationTitle?: StringOrMarkdown; - /** Kind of permission being requested. */ +export interface IAgentToolPendingConfirmationSignal { + readonly kind: 'pending_confirmation'; + readonly session: URI; + /** Protocol-shaped pending-confirmation state, dispatched verbatim into `SessionToolCallReady`. */ + readonly state: ToolCallPendingConfirmationState; + /** Host-only auto-approval kind (not part of the dispatched action). */ readonly permissionKind?: 'shell' | 'write' | 'mcp' | 'read' | 'url' | 'custom-tool'; - /** File path associated with the permission request. */ + /** Host-only auto-approval path target (not part of the dispatched action). */ readonly permissionPath?: string; - /** File edits this tool call will perform, for preview before confirmation. */ - readonly edits?: { items: FileEdit[] }; -} - -/** Streaming reasoning/thinking content from the assistant. */ -export interface IAgentReasoningEvent extends IAgentProgressEventBase { - readonly type: 'reasoning'; - readonly content: string; } /** - * The set of events returned by {@link IAgent.getSessionMessages} when - * reconstructing a session's history. Reasoning is carried inline on - * {@link IAgentMessageEvent.reasoningText} rather than as a separate event. + * A subagent was spawned by a tool call. The host creates a child session + * silently and routes subsequent inner-tool events to it. + * + * Kept as a non-action signal because subagent session creation has no + * protocol action — it's a host-side composition primitive. */ -export type SessionHistoryEvent = - | IAgentMessageEvent - | IAgentToolStartEvent - | IAgentToolCompleteEvent - | IAgentSubagentStartedEvent; - -/** A steering message was consumed (sent to the model). */ -export interface IAgentSteeringConsumedEvent extends IAgentProgressEventBase { - readonly type: 'steering_consumed'; - readonly id: string; -} - -/** The agent's ask_user tool is requesting user input. */ -export interface IAgentUserInputRequestEvent extends IAgentProgressEventBase { - readonly type: 'user_input_request'; - readonly request: SessionInputRequest; -} - -/** A subagent has been spawned by a tool call. */ -export interface IAgentSubagentStartedEvent extends IAgentProgressEventBase { - readonly type: 'subagent_started'; +export interface IAgentSubagentStartedSignal { + readonly kind: 'subagent_started'; + readonly session: URI; readonly toolCallId: string; readonly agentName: string; readonly agentDisplayName: string; readonly agentDescription?: string; } -/** Partial content update for a running tool call (e.g. terminal URI available). */ -export interface IAgentToolContentChangedEvent extends IAgentProgressEventBase { - readonly type: 'tool_content_changed'; - readonly toolCallId: string; - readonly content: ToolResultContent[]; +/** A steering message was consumed (sent to the model). */ +export interface IAgentSteeringConsumedSignal { + readonly kind: 'steering_consumed'; + readonly session: URI; + readonly id: string; } -export type IAgentProgressEvent = - | IAgentDeltaEvent - | IAgentMessageEvent - | IAgentIdleEvent - | IAgentToolStartEvent - | IAgentToolReadyEvent - | IAgentToolCompleteEvent - | IAgentTitleChangedEvent - | IAgentErrorEvent - | IAgentUsageEvent - | IAgentReasoningEvent - | IAgentSteeringConsumedEvent - | IAgentUserInputRequestEvent - | IAgentSubagentStartedEvent - | IAgentToolContentChangedEvent; - // ---- Session URI helpers ---------------------------------------------------- export namespace AgentSession { @@ -447,7 +336,7 @@ export interface IAgent { readonly id: AgentProvider; /** Fires when the provider streams progress for a session. */ - readonly onDidSessionProgress: Event; + readonly onDidSessionProgress: Event; /** Create a new session. Returns server-owned session metadata. */ createSession(config?: IAgentCreateSessionConfig): Promise; @@ -471,8 +360,14 @@ export interface IAgent { */ setPendingMessages?(session: URI, steeringMessage: PendingMessage | undefined, queuedMessages: readonly PendingMessage[]): void; - /** Retrieve all session events/messages for reconstruction. */ - getSessionMessages(session: URI): Promise; + /** + * Retrieve the reconstructed turns for a session, used when restoring + * sessions from persistent storage. Each agent owns the conversion from + * its SDK-specific event log to protocol {@link Turn}s, including + * subagent sessions (callers pass the subagent URI to retrieve the + * child session's turns). + */ + getSessionMessages(session: URI): Promise; /** Dispose a session, freeing resources. */ disposeSession(session: URI): Promise; diff --git a/src/vs/platform/agentHost/common/state/agentSubscription.ts b/src/vs/platform/agentHost/common/state/agentSubscription.ts index 582be627c5cea..41890e5cd7059 100644 --- a/src/vs/platform/agentHost/common/state/agentSubscription.ts +++ b/src/vs/platform/agentHost/common/state/agentSubscription.ts @@ -6,6 +6,7 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, IReference } from '../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../base/common/map.js'; +import { IObservable, observableFromEvent } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { ActionEnvelope, IRootConfigChangedAction, SessionAction, StateAction, isSessionAction } from './sessionActions.js'; import { rootReducer, sessionReducer } from './sessionReducers.js'; @@ -500,3 +501,18 @@ export class AgentSubscriptionManager extends Disposable { super.dispose(); } } + +// --- Observable Adapter ------------------------------------------------------ + +/** + * Adapts an {@link IAgentSubscription} into an {@link IObservable} of the + * subscription's value. Errors and the pre-snapshot phase are surfaced as + * `undefined`; consumers that need the error itself should read + * {@link IAgentSubscription.value} directly. + */ +export function observableFromSubscription(owner: object | undefined, sub: IAgentSubscription): IObservable { + return observableFromEvent(owner, sub.onDidChange, () => { + const v = sub.value; + return v instanceof Error ? undefined : v; + }); +} diff --git a/src/vs/platform/agentHost/node/agentEventMapper.ts b/src/vs/platform/agentHost/node/agentEventMapper.ts deleted file mode 100644 index 057cac4e586a8..0000000000000 --- a/src/vs/platform/agentHost/node/agentEventMapper.ts +++ /dev/null @@ -1,291 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { generateUuid } from '../../../base/common/uuid.js'; -import type { - IAgentDeltaEvent, - IAgentErrorEvent, - IAgentMessageEvent, - IAgentProgressEvent, - IAgentReasoningEvent, - IAgentTitleChangedEvent, - IAgentToolCompleteEvent, - IAgentToolContentChangedEvent, - IAgentToolStartEvent, - IAgentUsageEvent, - IAgentUserInputRequestEvent -} from '../common/agentService.js'; -import { - ActionType, - type SessionAction, - type SessionErrorAction, - type SessionInputRequestedAction, - type ITitleChangedAction, - type IToolCallCompleteAction, - type IToolCallReadyAction, - type IToolCallStartAction, - type SessionToolCallContentChangedAction, - type ITurnCompleteAction, - type IUsageAction -} from '../common/state/sessionActions.js'; -import { ResponsePartKind, ToolCallConfirmationReason, type URI } from '../common/state/sessionState.js'; - -/** - * Stateful mapper that tracks the "current" markdown and reasoning response - * parts per session/turn so that streaming deltas can be routed to the correct - * part via `partId`. - * - * Call {@link reset} when a new turn starts to clear tracked part IDs. - */ -export class AgentEventMapper { - /** Current markdown part ID per session. Reset on each new turn. */ - private readonly _currentMarkdownPartId = new Map(); - /** Current reasoning part ID per session. Reset on each new turn. */ - private readonly _currentReasoningPartId = new Map(); - - /** - * Resets tracked part IDs for a session (call when a new turn starts). - */ - reset(session: string): void { - this._currentMarkdownPartId.delete(session); - this._currentReasoningPartId.delete(session); - } - - /** - * Maps a flat {@link IAgentProgressEvent} from the agent host into - * protocol {@link SessionAction}(s) suitable for dispatch to the reducer. - * - * Returns `undefined` for events that have no corresponding action. - * May return an array when a single SDK event maps to multiple protocol actions. - */ - mapProgressEventToActions(event: IAgentProgressEvent, session: URI, turnId: string): SessionAction | SessionAction[] | undefined { - switch (event.type) { - case 'delta': { - const e = event as IAgentDeltaEvent; - const existingPartId = this._currentMarkdownPartId.get(session); - if (!existingPartId) { - // Create a new markdown part with the content directly - const partId = generateUuid(); - this._currentMarkdownPartId.set(session, partId); - return { - type: ActionType.SessionResponsePart, - session, - turnId, - part: { kind: ResponsePartKind.Markdown, id: partId, content: e.content }, - }; - } - return { - type: ActionType.SessionDelta, - session, - turnId, - partId: existingPartId, - content: e.content, - }; - } - - case 'tool_start': { - // A new tool call invalidates the current markdown and reasoning - // parts so the next text/reasoning delta creates fresh parts - // after the tool call. The Copilot SDK emits multiple rounds - // of (reasoning → message → tool calls) within a single chat - // turn; without this, every later round's reasoning would be - // appended onto the very first reasoning part and bunch at - // the top of the response on restore. - this._currentMarkdownPartId.delete(session); - this._currentReasoningPartId.delete(session); - - // The Copilot SDK provides full parameters at tool_start time. - // We emit both toolCallStart (streaming → created) and toolCallReady - // (params complete → running with auto-confirm) as a pair. - const e = event as IAgentToolStartEvent; - const meta: Record = { toolKind: e.toolKind, language: e.language }; - - // Subagent metadata is normalized by the per-SDK adapter (e.g. - // the Copilot adapter maps `agent_type` → `subagentAgentName`), - // so the generic mapper just forwards it as-is. - if (e.subagentDescription) { - meta.subagentDescription = e.subagentDescription; - } - if (e.subagentAgentName) { - meta.subagentAgentName = e.subagentAgentName; - } - - const startAction: IToolCallStartAction = { - type: ActionType.SessionToolCallStart, - session, - turnId, - toolCallId: e.toolCallId, - toolName: e.toolName, - displayName: e.displayName, - toolClientId: e.toolClientId, - _meta: meta, - }; - - // For client tools, do NOT auto-ready — the tool handler - // will fire a separate tool_ready event once the deferred - // is in place (or the permission flow fires it first). - if (e.toolClientId) { - return startAction; - } - - const readyAction: IToolCallReadyAction = { - type: ActionType.SessionToolCallReady, - session, - turnId, - toolCallId: e.toolCallId, - invocationMessage: e.invocationMessage, - toolInput: e.toolInput, - confirmed: ToolCallConfirmationReason.NotNeeded, - }; - return [startAction, readyAction]; - } - - case 'tool_ready': { - // Two scenarios: - // 1. Permission request: confirmationTitle is set → - // transition to PendingConfirmation (no `confirmed`). - // 2. Client tool auto-ready: confirmationTitle is absent → - // transition to Running (`confirmed: NotNeeded`). - const e = event; - return { - type: ActionType.SessionToolCallReady, - session, - turnId, - toolCallId: e.toolCallId, - invocationMessage: e.invocationMessage, - toolInput: e.toolInput, - confirmationTitle: e.confirmationTitle, - edits: e.edits, - ...(!e.confirmationTitle ? { confirmed: ToolCallConfirmationReason.NotNeeded } : {}), - } satisfies IToolCallReadyAction; - } - - case 'tool_complete': { - const e = event as IAgentToolCompleteEvent; - return { - type: ActionType.SessionToolCallComplete, - session, - turnId, - toolCallId: e.toolCallId, - result: e.result, - } satisfies IToolCallCompleteAction; - } - - case 'tool_content_changed': { - const e = event as IAgentToolContentChangedEvent; - return { - type: ActionType.SessionToolCallContentChanged, - session, - turnId, - toolCallId: e.toolCallId, - content: e.content, - } satisfies SessionToolCallContentChangedAction; - } - - case 'idle': - return { - type: ActionType.SessionTurnComplete, - session, - turnId, - } satisfies ITurnCompleteAction; - - case 'error': { - const e = event as IAgentErrorEvent; - return { - type: ActionType.SessionError, - session, - turnId, - error: { - errorType: e.errorType, - message: e.message, - stack: e.stack, - }, - } satisfies SessionErrorAction; - } - - case 'usage': { - const e = event as IAgentUsageEvent; - return { - type: ActionType.SessionUsage, - session, - turnId, - usage: { - inputTokens: e.inputTokens, - outputTokens: e.outputTokens, - model: e.model, - cacheReadTokens: e.cacheReadTokens, - }, - } satisfies IUsageAction; - } - - case 'title_changed': - return { - type: ActionType.SessionTitleChanged, - session, - title: (event as IAgentTitleChangedEvent).title, - } satisfies ITitleChangedAction; - - case 'reasoning': { - const e = event as IAgentReasoningEvent; - const existingPartId = this._currentReasoningPartId.get(session); - if (!existingPartId) { - // Create a new reasoning part with the content directly - const partId = generateUuid(); - this._currentReasoningPartId.set(session, partId); - return { - type: ActionType.SessionResponsePart, - session, - turnId, - part: { kind: ResponsePartKind.Reasoning, id: partId, content: e.content }, - }; - } - return { - type: ActionType.SessionReasoning, - session, - turnId, - partId: existingPartId, - content: e.content, - }; - } - - case 'message': { - // The SDK fires a `message` event with the complete assembled - // content after all streaming deltas. If delta events already - // captured the text (tracked via _currentMarkdownPartId), skip. - // Otherwise the text arrived without preceding deltas (e.g. - // after tool calls), so emit a new response part. - const e = event as IAgentMessageEvent; - if (e.role !== 'assistant' || !e.content) { - return undefined; - } - const existingPartId = this._currentMarkdownPartId.get(session); - if (existingPartId) { - // Deltas already streamed the content for this part - return undefined; - } - const partId = generateUuid(); - this._currentMarkdownPartId.set(session, partId); - return { - type: ActionType.SessionResponsePart, - session, - turnId, - part: { kind: ResponsePartKind.Markdown, id: partId, content: e.content }, - }; - } - - case 'user_input_request': { - const e = event as IAgentUserInputRequestEvent; - return { - type: ActionType.SessionInputRequested, - session, - request: e.request, - } satisfies SessionInputRequestedAction; - } - - default: - return undefined; - } - } -} diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 7fd43d6682919..f6e16cc79e324 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -15,12 +15,12 @@ import { FileSystemProviderErrorCode, IFileService, toFileSystemProviderErrorCod import { InstantiationService } from '../../instantiation/common/instantiationService.js'; import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; import { ILogService } from '../../log/common/log.js'; -import { AgentProvider, AgentSession, IAgent, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSubagentStartedEvent, IAgentToolStartEvent, SessionHistoryEvent, AuthenticateParams, AuthenticateResult } from '../common/agentService.js'; +import { AgentProvider, AgentSession, IAgent, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult } from '../common/agentService.js'; import { ISessionDataService } from '../common/sessionDataService.js'; import { ActionType, ActionEnvelope, INotification, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../common/state/sessionActions.js'; import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; -import { ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, parseSubagentSessionUri, readSessionGitState, withSessionGitState, type ResponsePart, type SessionConfigState, type ISessionFileDiff, type SessionSummary, type ToolCallCompletedState, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; +import { ResponsePartKind, SessionStatus, ToolCallStatus, ToolResultContentType, parseSubagentSessionUri, readSessionGitState, withSessionGitState, type SessionConfigState, type ISessionFileDiff, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; import { IProductService } from '../../product/common/productService.js'; import { AgentConfigurationService, IAgentConfigurationService } from './agentConfigurationService.js'; import { AgentSideEffects } from './agentSideEffects.js'; @@ -35,22 +35,6 @@ import { IAgentHostGitService } from './agentHostGitService.js'; * process. Dispatches to registered {@link IAgent} instances based * on the provider identifier in the session configuration. */ -/** - * Extracts subagent metadata from a tool start event. Adapters are - * responsible for normalizing their SDK-specific argument shape into the - * generic `subagentAgentName` / `subagentDescription` fields on the event - * itself, so this just forwards them. - */ -function extractSubagentMeta(start: IAgentToolStartEvent | undefined): { subagentDescription?: string; subagentAgentName?: string } { - if (!start) { - return {}; - } - return { - subagentDescription: start.subagentDescription, - subagentAgentName: start.subagentAgentName, - }; -} - export class AgentService extends Disposable implements IAgentService { declare readonly _serviceBrand: undefined; @@ -538,9 +522,9 @@ export class AgentService extends Disposable implements IAgentService { throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found on backend: ${sessionStr}`); } - let messages; + let turns: readonly Turn[]; try { - messages = await agent.getSessionMessages(session); + turns = await agent.getSessionMessages(session); } catch (err) { if (err instanceof ProtocolError) { throw err; @@ -548,7 +532,6 @@ export class AgentService extends Disposable implements IAgentService { const message = err instanceof Error ? err.message : String(err); throw new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Failed to restore session ${sessionStr}: ${message}`); } - const turns = this._buildTurnsFromMessages(messages); // Check for persisted metadata in the session database let title = meta.summary ?? 'Session'; @@ -615,7 +598,7 @@ export class AgentService extends Disposable implements IAgentService { diffs, }; - this._stateManager.restoreSession(summary, turns); + this._stateManager.restoreSession(summary, [...turns]); // Restore persisted `_meta` (e.g. git state) onto the new session // state. This dispatches a SessionMetaChanged action. @@ -764,266 +747,6 @@ export class AgentService extends Disposable implements IAgentService { // ---- helpers ------------------------------------------------------------ - /** - * Reconstructs completed `Turn[]` from a sequence of agent session - * messages. Each user-message starts a new turn; the assistant message - * closes it. - */ - private _buildTurnsFromMessages( - messages: readonly SessionHistoryEvent[], - ): Turn[] { - const turns: Turn[] = []; - // Track subagent metadata by parent tool call ID so we can inject - // ToolResultSubagentContent into the parent tool call's completion content - const subagentsByToolCallId = new Map(); - let currentTurn: { - id: string; - userMessage: { text: string }; - responseParts: ResponsePart[]; - pendingTools: Map; - } | undefined; - - const finalizeTurn = (turn: NonNullable, state: TurnState): void => { - turns.push({ - id: turn.id, - userMessage: turn.userMessage, - responseParts: turn.responseParts, - usage: undefined, - state, - }); - }; - - const startTurn = (id: string, text: string): NonNullable => ({ - id, - userMessage: { text }, - responseParts: [], - pendingTools: new Map(), - }); - - for (const msg of messages) { - if (msg.type === 'message' && msg.role === 'user') { - if (currentTurn) { - finalizeTurn(currentTurn, TurnState.Cancelled); - } - currentTurn = startTurn(msg.messageId, msg.content); - } else if (msg.type === 'message' && msg.role === 'assistant') { - // Skip inner assistant messages from subagent sessions. - // These have parentToolCallId set and belong to the child - // session, not the parent turn. - if (msg.parentToolCallId) { - continue; - } - if (!currentTurn) { - currentTurn = startTurn(msg.messageId, ''); - } - - // Reasoning is bundled onto the assistant message and - // logically precedes its content/tool calls. - if (msg.reasoningText) { - currentTurn.responseParts.push({ - kind: ResponsePartKind.Reasoning, - id: generateUuid(), - content: msg.reasoningText, - }); - } - - if (msg.content) { - currentTurn.responseParts.push({ - kind: ResponsePartKind.Markdown, - id: generateUuid(), - content: msg.content, - }); - } - - if (!msg.toolRequests || msg.toolRequests.length === 0) { - finalizeTurn(currentTurn, TurnState.Complete); - currentTurn = undefined; - } - } else if (msg.type === 'subagent_started') { - subagentsByToolCallId.set(msg.toolCallId, msg); - } else if (msg.type === 'tool_start') { - // Skip inner tool calls from subagent sessions — they belong - // to the child session, not the parent turn. - if (msg.parentToolCallId) { - continue; - } - currentTurn?.pendingTools.set(msg.toolCallId, msg); - } else if (msg.type === 'tool_complete') { - // Skip inner tool completions from subagent sessions. - if (msg.parentToolCallId) { - continue; - } - if (currentTurn) { - const start = currentTurn.pendingTools.get(msg.toolCallId); - currentTurn.pendingTools.delete(msg.toolCallId); - - // Inject subagent content if this tool call spawned a subagent - const subagentEvent = subagentsByToolCallId.get(msg.toolCallId); - const contentWithSubagent = msg.result.content ? [...msg.result.content] : []; - if (subagentEvent) { - const parentSessionStr = msg.session.toString(); - contentWithSubagent.push({ - type: ToolResultContentType.Subagent, - resource: buildSubagentSessionUri(parentSessionStr, msg.toolCallId), - title: subagentEvent.agentDisplayName, - agentName: subagentEvent.agentName, - description: subagentEvent.agentDescription, - }); - } - - const tc: ToolCallCompletedState = { - status: ToolCallStatus.Completed, - toolCallId: msg.toolCallId, - toolName: start?.toolName ?? 'unknown', - displayName: start?.displayName ?? 'Unknown Tool', - invocationMessage: start?.invocationMessage ?? 'Unknown tool', - toolInput: start?.toolInput, - success: msg.result.success, - pastTenseMessage: msg.result.pastTenseMessage, - content: contentWithSubagent.length > 0 ? contentWithSubagent : undefined, - error: msg.result.error, - confirmed: ToolCallConfirmationReason.NotNeeded, - _meta: { - toolKind: start?.toolKind, - language: start?.language, - ...extractSubagentMeta(start), - }, - }; - currentTurn.responseParts.push({ - kind: ResponsePartKind.ToolCall, - toolCall: tc, - }); - } - } - } - - if (currentTurn) { - finalizeTurn(currentTurn, TurnState.Cancelled); - } - - return turns; - } - - /** - * Builds turns for a subagent child session by extracting events - * from the parent session's messages that have the matching - * `parentToolCallId`. Creates a single turn containing all inner - * tool calls. - */ - private _buildSubagentTurns( - parentMessages: readonly SessionHistoryEvent[], - parentToolCallId: string, - childSessionUri: string, - ): Turn[] { - // Collect all inner tool call IDs that belong to this subagent - const innerToolCallIds = new Set(); - for (const msg of parentMessages) { - if ((msg.type === 'tool_start' || msg.type === 'tool_complete') && msg.parentToolCallId === parentToolCallId) { - innerToolCallIds.add(msg.toolCallId); - } - } - - // Collect subagent_started events for nested subagents spawned by - // inner tool calls of this child session - const subagentsByToolCallId = new Map(); - for (const msg of parentMessages) { - if (msg.type === 'subagent_started' && innerToolCallIds.has(msg.toolCallId)) { - subagentsByToolCallId.set(msg.toolCallId, msg); - } - } - - // Filter for events belonging to this subagent - const innerMessages = parentMessages.filter(msg => { - if (msg.type === 'tool_start' || msg.type === 'tool_complete') { - return msg.parentToolCallId === parentToolCallId; - } - if (msg.type === 'message') { - return msg.parentToolCallId === parentToolCallId; - } - return false; - }); - - if (innerMessages.length === 0) { - return []; - } - - // Build a single turn with all inner tool calls - const responseParts: ResponsePart[] = []; - const pendingTools = new Map(); - - for (const msg of innerMessages) { - if (msg.type === 'tool_start') { - pendingTools.set(msg.toolCallId, msg); - } else if (msg.type === 'tool_complete') { - const start = pendingTools.get(msg.toolCallId); - pendingTools.delete(msg.toolCallId); - - // Inject nested subagent content if applicable - const subagentEvent = subagentsByToolCallId.get(msg.toolCallId); - const contentWithSubagent = msg.result.content ? [...msg.result.content] : []; - if (subagentEvent) { - contentWithSubagent.push({ - type: ToolResultContentType.Subagent, - resource: buildSubagentSessionUri(childSessionUri, msg.toolCallId), - title: subagentEvent.agentDisplayName, - agentName: subagentEvent.agentName, - description: subagentEvent.agentDescription, - }); - } - - const tc: ToolCallCompletedState = { - status: ToolCallStatus.Completed, - toolCallId: msg.toolCallId, - toolName: start?.toolName ?? 'unknown', - displayName: start?.displayName ?? 'Unknown Tool', - invocationMessage: start?.invocationMessage ?? 'Unknown tool', - toolInput: start?.toolInput, - success: msg.result.success, - pastTenseMessage: msg.result.pastTenseMessage, - content: contentWithSubagent.length > 0 ? contentWithSubagent : undefined, - error: msg.result.error, - confirmed: ToolCallConfirmationReason.NotNeeded, - _meta: { - toolKind: start?.toolKind, - language: start?.language, - ...extractSubagentMeta(start), - }, - }; - responseParts.push({ - kind: ResponsePartKind.ToolCall, - toolCall: tc, - }); - } else if (msg.type === 'message' && msg.role === 'assistant') { - if (msg.reasoningText) { - responseParts.push({ - kind: ResponsePartKind.Reasoning, - id: generateUuid(), - content: msg.reasoningText, - }); - } - if (msg.content) { - responseParts.push({ - kind: ResponsePartKind.Markdown, - id: generateUuid(), - content: msg.content, - }); - } - } - } - - if (responseParts.length === 0) { - return []; - } - - return [{ - id: generateUuid(), - userMessage: { text: '' }, - responseParts, - usage: undefined, - state: TurnState.Complete, - }]; - } - private async _fetchSessionDbContent(fields: ISessionDbUriFields): Promise { const sessionUri = URI.parse(fields.sessionUri); const ref = this._sessionDataService.openDatabase(sessionUri); @@ -1119,15 +842,15 @@ export class AgentService extends Disposable implements IAgentService { } } - // Load parent's raw messages and extract inner events for this subagent - let childTurns: Turn[] = []; + // Load the subagent's turns from the agent (which knows how to + // extract them from the parent session's event log). + let childTurns: readonly Turn[] = []; const agent = this._findProviderForSession(parentUri); if (agent) { try { - const messages = await agent.getSessionMessages(parentUri); - childTurns = this._buildSubagentTurns(messages, toolCallId, subagentUri); + childTurns = await agent.getSessionMessages(URI.parse(subagentUri)); } catch (err) { - this._logService.warn(`[AgentService] Failed to load parent messages for subagent restore: ${subagentUri}`, err); + this._logService.warn(`[AgentService] Failed to load subagent turns for ${subagentUri}`, err); } } @@ -1144,7 +867,7 @@ export class AgentService extends Disposable implements IAgentService { modifiedAt: Date.now(), ...(parentState?.summary.project ? { project: parentState.summary.project } : {}), }, - childTurns, + [...childTurns], ); this._logService.info(`[AgentService] Restored subagent session: ${subagentUri} with ${childTurns.length} turn(s)`); } diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index d13343ab852a2..07b71f4d8be50 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -12,11 +12,11 @@ import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { ILogService } from '../../log/common/log.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; -import { IAgent, IAgentAttachment, IAgentProgressEvent, type IAgentToolCompleteEvent, type IAgentToolReadyEvent } from '../common/agentService.js'; +import { AgentSignal, IAgent, IAgentAttachment, IAgentToolPendingConfirmationSignal } from '../common/agentService.js'; import { IDiffComputeService } from '../common/diffComputeService.js'; import { ISessionDatabase, ISessionDataService } from '../common/sessionDataService.js'; import type { AgentInfo } from '../common/state/protocol/state.js'; -import { ActionType, StateAction } from '../common/state/sessionActions.js'; +import { ActionType, isSessionAction, StateAction, type SessionToolCallCompleteAction } from '../common/state/sessionActions.js'; import { PendingMessageKind, ResponsePartKind, @@ -30,7 +30,6 @@ import { type ISessionFileDiff, type URI as ProtocolURI, } from '../common/state/sessionState.js'; -import { AgentEventMapper } from './agentEventMapper.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; import { IAgentHostGitService, META_DIFF_BASE_BRANCH } from './agentHostGitService.js'; import { NodeWorkerDiffComputeService } from './diffComputeService.js'; @@ -55,11 +54,10 @@ export interface IAgentSideEffectsOptions { readonly onTurnComplete: (session: ProtocolURI) => void; } -/** A progress event that was deferred because its subagent session does not exist yet. */ -interface IPendingSubagentEvent { - readonly event: IAgentProgressEvent; +/** A signal that was deferred because its subagent session does not exist yet. */ +interface IPendingSubagentSignal { + readonly signal: AgentSignal; readonly agent: IAgent; - readonly agentMapper: AgentEventMapper; } /** @@ -76,8 +74,6 @@ export class AgentSideEffects extends Disposable { /** Maps tool call IDs to the agent that owns them, for routing confirmations. */ private readonly _toolCallAgents = new Map(); - /** Per-agent event mapper instances (stateful for partId tracking). */ - private readonly _eventMappers = new Map(); /** Shared diff compute service for calculating line-level diffs in a worker thread. */ private readonly _diffComputeService: IDiffComputeService; /** Serializes per-session diff computations to avoid races with stale previousDiffs. */ @@ -91,22 +87,22 @@ export class AgentSideEffects extends Disposable { /** * Maps `parentSession:toolCallId` → subagent session URI. - * Used to route events with `parentToolCallId` to the correct subagent. + * Used to route signals with `parentToolCallId` to the correct subagent. */ private readonly _subagentSessions = new Map(); /** - * Buffers progress events whose `parentToolCallId` references a subagent - * whose `subagent_started` event has not yet been processed. The SDK is + * Buffers signals whose `parentToolCallId` references a subagent + * whose `subagent_started` signal has not yet been processed. The SDK is * not strict about ordering: an inner `tool_start` can arrive before the * `subagent_started` that creates the child session. Without buffering, - * those events would be dispatched against the parent session and the + * those signals would be dispatched against the parent session and the * UI would render the inner tool calls flat at the top level rather than * grouping them under the subagent. Drained by `_handleSubagentStarted`. * * Key: `${parentSession}:${parentToolCallId}`. */ - private readonly _pendingSubagentEvents = new Map(); + private readonly _pendingSubagentSignals = new Map(); constructor( private readonly _stateManager: AgentHostStateManager, @@ -218,21 +214,14 @@ export class AgentSideEffects extends Disposable { // ---- Agent registration ------------------------------------------------- /** - * Registers a progress-event listener on the given agent so that - * `IAgentProgressEvent`s are mapped to protocol actions and dispatched - * through the state manager. Returns a disposable that removes the - * listener. + * Registers a progress-signal listener on the given agent so that + * {@link AgentSignal}s are routed/dispatched through the state manager. + * Returns a disposable that removes the listener. */ registerProgressListener(agent: IAgent): IDisposable { const disposables = new DisposableStore(); - let mapper = this._eventMappers.get(agent.id); - if (!mapper) { - mapper = new AgentEventMapper(); - this._eventMappers.set(agent.id, mapper); - } - const agentMapper = mapper; - disposables.add(agent.onDidSessionProgress(e => { - this._handleAgentProgress(agent, agentMapper, e); + disposables.add(agent.onDidSessionProgress(signal => { + this._handleAgentSignal(agent, signal); })); if (agent.onDidCustomizationsChange) { disposables.add(agent.onDidCustomizationsChange(() => { @@ -244,79 +233,84 @@ export class AgentSideEffects extends Disposable { } /** - * Routes a single progress event from `agent` to the correct session. + * Routes a single signal from `agent` to the correct session. * - * Events with a `parentToolCallId` are routed to the matching subagent - * session. If the subagent session does not exist yet (the SDK can emit - * an inner `tool_start` before its `subagent_started`), the event is - * buffered in `_pendingSubagentEvents` and replayed once the - * `subagent_started` arrives. + * Action signals with a `parentToolCallId` are routed to the matching + * subagent session. If the subagent session does not exist yet (the SDK + * can emit an inner `tool_start` before its `subagent_started`), the + * signal is buffered in {@link _pendingSubagentSignals} and replayed + * once the `subagent_started` arrives. */ - private _handleAgentProgress(agent: IAgent, agentMapper: AgentEventMapper, e: IAgentProgressEvent): void { - const sessionKey = e.session.toString(); + private _handleAgentSignal(agent: IAgent, signal: AgentSignal): void { + const sessionKey = signal.session.toString(); // Track tool calls so handleAction can route confirmations. Defer - // registration for inner subagent tool calls (those carrying a - // `parentToolCallId`) until we know which subagent session they - // belong to — otherwise we'd register them under the parent - // session key and a later `tool_ready` (which lacks + // registration for inner subagent tool calls until we know which + // subagent session they belong to — otherwise we'd register them + // under the parent session key and a later `pending_confirmation` + // (which lacks // `parentToolCallId`) could be routed against the wrong session. - if (e.type === 'tool_start' && !this._getParentToolCallId(e)) { - this._toolCallAgents.set(`${sessionKey}:${e.toolCallId}`, agent.id); + if (signal.kind === 'action' + && signal.action.type === ActionType.SessionToolCallStart + && !signal.parentToolCallId + ) { + this._toolCallAgents.set(`${sessionKey}:${signal.action.toolCallId}`, agent.id); } - // Handle subagent_started: create the subagent session, then drain - // any inner events that arrived before us. - if (e.type === 'subagent_started') { - this._handleSubagentStarted(sessionKey, e.toolCallId, e.agentName, e.agentDisplayName, e.agentDescription); - this._drainPendingSubagentEvents(sessionKey, e.toolCallId); + if (signal.kind === 'subagent_started') { + this._handleSubagentStarted(sessionKey, signal.toolCallId, signal.agentName, signal.agentDisplayName, signal.agentDescription); + this._drainPendingSubagentSignals(sessionKey, signal.toolCallId); return; } - // Route events with parentToolCallId to the subagent session. - const parentToolCallId = this._getParentToolCallId(e); + if (signal.kind === 'steering_consumed') { + this._stateManager.dispatchServerAction({ + type: ActionType.SessionPendingMessageRemoved, + session: sessionKey, + kind: PendingMessageKind.Steering, + id: signal.id, + }); + return; + } + + // Route signals with parentToolCallId to the subagent session. + const parentToolCallId = signal.kind === 'action' ? signal.parentToolCallId : undefined; if (parentToolCallId) { const subagentKey = `${sessionKey}:${parentToolCallId}`; const subagentSession = this._subagentSessions.get(subagentKey); if (subagentSession) { - // Track tool calls in subagent context for confirmation routing - if (e.type === 'tool_start') { - this._toolCallAgents.set(`${subagentSession}:${e.toolCallId}`, agent.id); + // Track tool calls in subagent context for confirmation routing. + if (signal.kind === 'action' && signal.action.type === ActionType.SessionToolCallStart) { + this._toolCallAgents.set(`${subagentSession}:${signal.action.toolCallId}`, agent.id); } const subTurnId = this._stateManager.getActiveTurnId(subagentSession); if (subTurnId) { - if (e.type === 'tool_ready') { - this._handleToolReady(e, subagentSession, subTurnId, agent); - } else { - this._dispatchProgressActions(agentMapper, e, subagentSession, subTurnId); - } + this._dispatchActionForSession(signal, subagentSession, subTurnId); } return; } - // Subagent session does not exist yet — buffer the event so we - // can replay it after `subagent_started` arrives. Without this, - // inner tool calls would leak into the parent session and the - // UI would render them flat at the top level. - this._logService.trace(`[AgentSideEffects] Buffering ${e.type} for pending subagent ${subagentKey}`); - let buffer = this._pendingSubagentEvents.get(subagentKey); + // Subagent session does not exist yet — buffer the signal so we can + // replay it after `subagent_started` arrives. + this._logService.trace(`[AgentSideEffects] Buffering ${this._describeSignal(signal)} for pending subagent ${subagentKey}`); + let buffer = this._pendingSubagentSignals.get(subagentKey); if (!buffer) { buffer = []; - this._pendingSubagentEvents.set(subagentKey, buffer); + this._pendingSubagentSignals.set(subagentKey, buffer); } - buffer.push({ event: e, agent, agentMapper }); + buffer.push({ signal, agent }); return; } - // Route tool_ready events for tools inside subagent sessions - // (tool_ready lacks parentToolCallId, but the tool was previously - // registered under its subagent session key in _toolCallAgents) - if (e.type === 'tool_ready') { - const subagentSession = this._findSubagentSessionForToolCall(sessionKey, e.toolCallId); + // Route pending_confirmation signals for tools inside subagent sessions + // (the signal lacks parentToolCallId, but the tool was previously + // registered under its subagent session key in _toolCallAgents). + if (signal.kind === 'pending_confirmation') { + const subagentSession = this._findSubagentSessionForToolCall(sessionKey, signal.state.toolCallId); if (subagentSession) { const subTurnId = this._stateManager.getActiveTurnId(subagentSession); if (subTurnId) { - this._handleToolReady(e, subagentSession, subTurnId, agent); + this._handleToolReady(signal, subagentSession, subTurnId, agent); } return; } @@ -324,77 +318,115 @@ export class AgentSideEffects extends Disposable { const turnId = this._stateManager.getActiveTurnId(sessionKey); if (turnId) { - if (e.type === 'tool_ready') { - this._handleToolReady(e, sessionKey, turnId, agent); - return; + this._dispatchActionForSession(signal, sessionKey, turnId, agent); + return; + } + + // No active turn on the session. Most signals are silently dropped, + // but a `SessionTurnComplete` (idle) still needs to drive its + // post-turn side effects — flushing pending diff computation, + // recomputing diffs, and notifying the host. Tests routinely fire + // `idle` without first dispatching the matching `SessionTurnStarted` + // through the state manager. + if (signal.kind === 'action' && signal.action.type === ActionType.SessionTurnComplete) { + this._runTurnCompleteSideEffects(sessionKey, undefined); + } + } + + /** + * Dispatches a signal against a resolved session+turn. Performs the + * subagent-content merge for tool_complete and the related side effects. + */ + private _dispatchActionForSession(signal: AgentSignal, sessionKey: ProtocolURI, turnId: string, agent?: IAgent): void { + if (signal.kind === 'pending_confirmation') { + if (agent) { + this._handleToolReady(signal, sessionKey, turnId, agent); } + return; + } + if (signal.kind !== 'action') { + return; + } + // The agent emits actions with its own view of the active turnId + // targeting the top-level session. The state manager is the source + // of truth — rewrite `session` and `turnId` so the action lands in + // the right reducer (subagent session for routed signals, queued + // turn ID when the agent hasn't yet seen `sendMessage`, etc.). + // Actions without a `turnId` field (`SessionTitleChanged`, + // `SessionInputRequested`) only get their `session` rewritten. + let action = signal.action; + if (isSessionAction(action) && action.session !== sessionKey) { + action = { ...action, session: sessionKey }; + } + if (hasKey(action, { turnId: true }) && action.turnId !== turnId) { + action = { ...action, turnId }; + } - // When a parent tool call has an associated subagent session, - // preserve the subagent content metadata in the completion - // result. The SDK's tool_complete provides its own content - // which would overwrite the ToolResultSubagentContent that - // was set via SessionToolCallContentChanged while running. - if (e.type === 'tool_complete') { - const subagentKey = `${sessionKey}:${e.toolCallId}`; - const subagentUri = this._subagentSessions.get(subagentKey); - if (subagentUri) { - const parentState = this._stateManager.getSessionState(sessionKey); - const runningContent = this._getRunningToolCallContent(parentState, turnId, e.toolCallId); - const subagentEntry = runningContent.find(c => hasKey(c, { type: true }) && c.type === ToolResultContentType.Subagent); - if (subagentEntry) { - const mergedContent = [...(e.result.content ?? []), subagentEntry]; - e = { ...e, result: { ...e.result, content: mergedContent } }; - } + // When a parent tool call has an associated subagent session, + // preserve the subagent content metadata in the completion result. + // The SDK's tool_complete provides its own content which would + // overwrite the ToolResultSubagentContent that was set via + // SessionToolCallContentChanged while running. + if (action.type === ActionType.SessionToolCallComplete) { + const subagentKey = `${sessionKey}:${action.toolCallId}`; + const subagentUri = this._subagentSessions.get(subagentKey); + if (subagentUri) { + const parentState = this._stateManager.getSessionState(sessionKey); + const runningContent = this._getRunningToolCallContent(parentState, turnId, action.toolCallId); + const subagentEntry = runningContent.find(c => hasKey(c, { type: true }) && c.type === ToolResultContentType.Subagent); + if (subagentEntry) { + const mergedContent = [...(action.result.content ?? []), subagentEntry]; + const merged: SessionToolCallCompleteAction = { ...action, result: { ...action.result, content: mergedContent } }; + action = merged; } } + } - this._dispatchProgressActions(agentMapper, e, sessionKey, turnId); + this._stateManager.dispatchServerAction(action); - // When a parent tool call completes, complete any associated subagent session - if (e.type === 'tool_complete') { - this.completeSubagentSession(sessionKey, e.toolCallId); - if (getToolFileEdits((e as IAgentToolCompleteEvent).result).length > 0) { - this._scheduleDebouncedDiffComputation(sessionKey, turnId); - } + if (action.type === ActionType.SessionToolCallComplete) { + this.completeSubagentSession(sessionKey, action.toolCallId); + if (getToolFileEdits(action.result).length > 0) { + this._scheduleDebouncedDiffComputation(sessionKey, turnId); } } - // After a turn completes (idle event), flush any pending debounced - // diff computation and compute final diffs immediately, then refresh - // git state so the toolbar buttons reflect post-turn repository state. - if (e.type === 'idle') { - this._cancelDebouncedDiffComputation(sessionKey); - this._computeSessionDiffs(sessionKey, turnId); - this._tryConsumeNextQueuedMessage(sessionKey); - this._options.onTurnComplete(sessionKey as ProtocolURI); + if (action.type === ActionType.SessionTurnComplete) { + this._runTurnCompleteSideEffects(sessionKey, turnId); } + } - // Steering message was consumed by the agent — remove from protocol state - if (e.type === 'steering_consumed') { - this._stateManager.dispatchServerAction({ - type: ActionType.SessionPendingMessageRemoved, - session: sessionKey, - kind: PendingMessageKind.Steering, - id: e.id, - }); - } + /** + * Post-turn side effects: flush any pending debounced diff computation, + * compute final diffs immediately, drain the next queued message, and + * notify the host so it can refresh git state. + */ + private _runTurnCompleteSideEffects(sessionKey: ProtocolURI, turnId: string | undefined): void { + this._cancelDebouncedDiffComputation(sessionKey); + this._computeSessionDiffs(sessionKey, turnId); + this._tryConsumeNextQueuedMessage(sessionKey); + this._options.onTurnComplete(sessionKey); + } + + private _describeSignal(signal: AgentSignal): string { + return signal.kind === 'action' ? `action(${signal.action.type})` : signal.kind; } /** - * Replays any progress events that were buffered while waiting for + * Replays any signals that were buffered while waiting for * `subagent_started` to create the subagent session. Called immediately * after `_handleSubagentStarted`. */ - private _drainPendingSubagentEvents(parentSession: ProtocolURI, parentToolCallId: string): void { + private _drainPendingSubagentSignals(parentSession: ProtocolURI, parentToolCallId: string): void { const subagentKey = `${parentSession}:${parentToolCallId}`; - const buffer = this._pendingSubagentEvents.get(subagentKey); + const buffer = this._pendingSubagentSignals.get(subagentKey); if (!buffer) { return; } - this._pendingSubagentEvents.delete(subagentKey); - this._logService.trace(`[AgentSideEffects] Draining ${buffer.length} buffered event(s) for subagent ${subagentKey}`); - for (const { event, agent, agentMapper } of buffer) { - this._handleAgentProgress(agent, agentMapper, event); + this._pendingSubagentSignals.delete(subagentKey); + this._logService.trace(`[AgentSideEffects] Draining ${buffer.length} buffered signal(s) for subagent ${subagentKey}`); + for (const { signal, agent } of buffer) { + this._handleAgentSignal(agent, signal); } } @@ -511,9 +543,9 @@ export class AgentSideEffects extends Disposable { } } // Drop any buffered events targeted at subagents that never started. - for (const key of [...this._pendingSubagentEvents.keys()]) { + for (const key of [...this._pendingSubagentSignals.keys()]) { if (key.startsWith(`${parentSession}:`)) { - this._pendingSubagentEvents.delete(key); + this._pendingSubagentSignals.delete(key); } } } @@ -529,7 +561,7 @@ export class AgentSideEffects extends Disposable { // that never arrived (e.g. the parent tool failed before the subagent // was created). Without this, the buffer entry would leak until the // parent session is disposed. - this._pendingSubagentEvents.delete(key); + this._pendingSubagentSignals.delete(key); const subagentUri = this._subagentSessions.get(key); if (!subagentUri) { @@ -571,28 +603,13 @@ export class AgentSideEffects extends Disposable { } // Drop any buffered events targeted at subagents that never started. - for (const key of [...this._pendingSubagentEvents.keys()]) { + for (const key of [...this._pendingSubagentSignals.keys()]) { if (key.startsWith(`${parentSession}:`)) { - this._pendingSubagentEvents.delete(key); + this._pendingSubagentSignals.delete(key); } } } - /** - * Extracts the `parentToolCallId` from a progress event, if present. - */ - private _getParentToolCallId(e: IAgentProgressEvent): string | undefined { - switch (e.type) { - case 'delta': - case 'message': - case 'tool_start': - case 'tool_complete': - return e.parentToolCallId; - default: - return undefined; - } - } - /** * Finds the subagent session that owns a given tool call by checking * whether the tool call was previously registered under a subagent @@ -611,43 +628,39 @@ export class AgentSideEffects extends Disposable { // ---- Side-effect handlers -------------------------------------------------- - private _dispatchProgressActions(mapper: AgentEventMapper, e: IAgentProgressEvent, sessionKey: ProtocolURI, turnId: string): void { - const actions = mapper.mapProgressEventToActions(e, sessionKey, turnId); - if (actions) { - if (Array.isArray(actions)) { - for (const action of actions) { - this._stateManager.dispatchServerAction(action); - } - } else { - this._stateManager.dispatchServerAction(actions); - } - } - } - /** - * Handles a `tool_ready` event end-to-end: checks for auto-approval via - * the permission manager, and if not auto-approved, dispatches the - * `SessionToolCallReady` action with confirmation options for the client. + * Handles a `pending_confirmation` signal end-to-end: checks for + * auto-approval via the permission manager, and if not auto-approved, + * dispatches the `SessionToolCallReady` action with confirmation options + * for the client. */ - private _handleToolReady(e: IAgentToolReadyEvent, sessionKey: ProtocolURI, turnId: string, agent: IAgent): void { - const autoApproval = this._permissionManager.getAutoApproval(e, sessionKey); + private _handleToolReady(e: IAgentToolPendingConfirmationSignal, sessionKey: ProtocolURI, turnId: string, agent: IAgent): void { + const approvalEvent = { + toolCallId: e.state.toolCallId, + session: e.session, + permissionKind: e.permissionKind, + permissionPath: e.permissionPath, + toolInput: e.state.toolInput, + }; + const autoApproval = this._permissionManager.getAutoApproval(approvalEvent, sessionKey); + let effective = e; if (autoApproval !== undefined) { - this._toolCallAgents.delete(`${sessionKey}:${e.toolCallId}`); - agent.respondToPermissionRequest(e.toolCallId, true); - e = { ...e, confirmationTitle: undefined }; // don't trigger confirmation + this._toolCallAgents.delete(`${sessionKey}:${e.state.toolCallId}`); + agent.respondToPermissionRequest(e.state.toolCallId, true); + // Strip confirmationTitle so createToolReadyAction emits the + // auto-approved (no-options) action. + effective = { ...e, state: { ...e.state, confirmationTitle: undefined } }; } this._stateManager.dispatchServerAction( - this._permissionManager.createToolReadyAction(e, sessionKey, turnId) + this._permissionManager.createToolReadyAction(effective, sessionKey, turnId) ); } handleAction(action: StateAction): void { switch (action.type) { case ActionType.SessionTurnStarted: { - // Reset the event mapper's part tracking for the new turn - for (const mapper of this._eventMappers.values()) { - mapper.reset(action.session); - } + // Per-turn streaming part tracking is owned by the agent + // (e.g. CopilotAgentSession) and reset on its `send()` call. // On the very first turn, immediately set the session title to the // user's message so the UI shows a meaningful title right away @@ -731,7 +744,7 @@ export class AgentSideEffects extends Disposable { break; } case ActionType.SessionTitleChanged: { - this._persistTitle(action.session, action.title); + this._persistSessionFlag(action.session, 'customTitle', action.title); break; } case ActionType.SessionPendingMessageSet: @@ -823,15 +836,11 @@ export class AgentSideEffects extends Disposable { } } - private _persistTitle(session: ProtocolURI, title: string): void { - const ref = this._options.sessionDataService.openDatabase(URI.parse(session)); - ref.object.setMetadata('customTitle', title).catch(err => { - this._logService.warn('[AgentSideEffects] Failed to persist session title', err); - }).finally(() => { - ref.dispose(); - }); - } - + /** + * Persists a session metadata key/value pair to the session database. + * Used for fields the host needs to remember across restarts (custom + * title, isRead/isArchived flags, merged config values). + */ private _persistSessionFlag(session: ProtocolURI, key: string, value: string): void { const ref = this._options.sessionDataService.openDatabase(URI.parse(session)); ref.object.setMetadata(key, value).catch(err => { @@ -886,10 +895,8 @@ export class AgentSideEffects extends Disposable { const msg = state.queuedMessages[0]; const turnId = generateUuid(); - // Reset event mappers for the new turn (same as handleAction does for SessionTurnStarted) - for (const mapper of this._eventMappers.values()) { - mapper.reset(session); - } + // Per-turn streaming part tracking is owned by the agent (reset + // inside its `send()` call), so no host-side reset is needed. // Dispatch server-initiated turn start; the reducer removes the queued message atomically this._stateManager.dispatchServerAction({ diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 88c588345d911..bae7868abbb94 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -24,14 +24,14 @@ import { IInstantiationService } from '../../../instantiation/common/instantiati import { ILogService } from '../../../log/common/log.js'; import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; -import { AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentDeltaEvent, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, SessionHistoryEvent } from '../../common/agentService.js'; +import { AgentSession, AgentSignal, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo } from '../../common/agentService.js'; import { AutoApproveLevel, ISchemaProperty, createSchema, platformSessionSchema, schemaProperty } from '../../common/agentHostSchema.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ISessionDataService, SESSION_DB_FILENAME } from '../../common/sessionDataService.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { ProtectedResourceMetadata, type ConfigSchema, type ModelSelection, type SessionCustomization, type ToolDefinition } from '../../common/state/protocol/state.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; -import { CustomizationStatus, CustomizationRef, SessionInputResponseKind, type PendingMessage, type SessionInputAnswer, type ToolCallResult, type PolicyState } from '../../common/state/sessionState.js'; +import { CustomizationStatus, CustomizationRef, ResponsePartKind, SessionInputResponseKind, parseSubagentSessionUri, type PendingMessage, type ResponsePart, type SessionInputAnswer, type ToolCallResult, type Turn, type PolicyState } from '../../common/state/sessionState.js'; import { IAgentHostGitService, META_DIFF_BASE_BRANCH } from '../agentHostGitService.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; @@ -123,31 +123,39 @@ function buildWorktreeAnnouncementText(branchName: string): string { ) + '\n\n'; } -type AgentMessageOrEvent = SessionHistoryEvent; - /** - * Returns a copy of `messages` where `announcement` has been prepended to - * the first top-level assistant message's content. Subagent inner messages - * (those with a `parentToolCallId`) are skipped so the announcement lands - * on the parent turn. If no assistant message exists yet, returns the - * messages unchanged — the live announcement path is responsible for the - * very first turn before any reply has been recorded. + * Returns a copy of `turns` where `announcement` has been prepended to the + * first top-level assistant turn's first markdown response part. Used on + * session restore so the worktree announcement remains visible after the + * session is reopened. If no assistant content exists yet, a fresh + * markdown part is inserted at the top of the first turn. */ -function prependAnnouncementToFirstAssistantMessage( - messages: readonly AgentMessageOrEvent[], +function prependAnnouncementToFirstTurn( + turns: readonly Turn[], announcement: string, -): readonly AgentMessageOrEvent[] { - const firstAssistantIdx = messages.findIndex(m => m.type === 'message' && m.role === 'assistant' && !m.parentToolCallId); - if (firstAssistantIdx === -1) { - return messages; - } - const target = messages[firstAssistantIdx] as IAgentMessageEvent; - const updated: IAgentMessageEvent = { ...target, content: announcement + target.content }; - return [ - ...messages.slice(0, firstAssistantIdx), - updated, - ...messages.slice(firstAssistantIdx + 1), - ]; +): readonly Turn[] { + if (turns.length === 0) { + return turns; + } + const result = turns.slice(); + const first = result[0]; + const partIdx = first.responseParts.findIndex(rp => rp.kind === ResponsePartKind.Markdown); + if (partIdx >= 0) { + const part = first.responseParts[partIdx]; + const updated: ResponsePart = part.kind === ResponsePartKind.Markdown + ? { ...part, content: announcement + part.content } + : part; + const responseParts = first.responseParts.slice(); + responseParts[partIdx] = updated; + result[0] = { ...first, responseParts }; + } else { + const responseParts: ResponsePart[] = [ + { kind: ResponsePartKind.Markdown, id: generateUuid(), content: announcement }, + ...first.responseParts, + ]; + result[0] = { ...first, responseParts }; + } + return result; } /** @@ -157,7 +165,7 @@ export class CopilotAgent extends Disposable implements IAgent { readonly id = 'copilotcli' as const; private static readonly _BRANCH_COMPLETION_LIMIT = 25; - private readonly _onDidSessionProgress = this._register(new Emitter()); + private readonly _onDidSessionProgress = this._register(new Emitter()); readonly onDidSessionProgress = this._onDidSessionProgress.event; private readonly _models = observableValue(this, []); readonly models = this._models; @@ -700,18 +708,22 @@ export class CopilotAgent extends Disposable implements IAgent { } entry ??= await this._resumeSession(sessionId); - // Emit any pending first-turn announcements (e.g. worktree - // created) as a synthetic streaming delta before delegating to - // the SDK. The mapper treats it like any other assistant text — - // the SDK's subsequent deltas append to the same markdown part. - // The active turn has already been started by the state manager - // at this point, so the event mapper can attach the delta to it. - const pending = this._pendingFirstTurnAnnouncements.get(sessionId); - if (pending) { + // Reset per-turn streaming state on the session so that the + // next text/reasoning chunk (and any host-emitted announcement) + // allocates a fresh response part. + if (turnId) { + entry.resetTurnState(turnId); + } + + // Emit any pending first-turn announcement (e.g. worktree + // created) as a synthetic markdown response part before + // delegating to the SDK. The SDK's subsequent deltas append to + // the same markdown part because the session has already + // allocated `_currentMarkdownPartId`. + const announcement = this._pendingFirstTurnAnnouncements.get(sessionId); + if (announcement !== undefined) { this._pendingFirstTurnAnnouncements.delete(sessionId); - const messageId = `copilot-announcement-${generateUuid()}`; - const event: IAgentDeltaEvent = { type: 'delta', session, messageId, content: pending }; - this._onDidSessionProgress.fire(event); + entry.emitInitialMarkdown(announcement); } try { @@ -743,7 +755,23 @@ export class CopilotAgent extends Disposable implements IAgent { // No SDK-level enqueue is needed. } - async getSessionMessages(session: URI): Promise { + async getSessionMessages(session: URI): Promise { + // If the URI describes a subagent child session (`/subagent/`), + // load the parent's events once and extract the child's filtered turns. + const subagentInfo = parseSubagentSessionUri(session.toString()); + if (subagentInfo) { + const parentUri = URI.parse(subagentInfo.parentSession); + const parentSessionId = AgentSession.id(parentUri); + const parentEntry = this._sessions.get(parentSessionId) ?? await this._resumeSession(parentSessionId).catch(err => { + this._logService.warn(`[Copilot:${parentSessionId}] Failed to resume parent for subagent restore`, err); + return undefined; + }); + if (!parentEntry) { + return []; + } + return parentEntry.getSubagentMessages(subagentInfo.toolCallId, session.toString()); + } + const sessionId = AgentSession.id(session); const entry = this._sessions.get(sessionId) ?? await this._resumeSession(sessionId).catch(err => { this._logService.warn(`[Copilot:${sessionId}] Failed to resume session for message lookup`, err); @@ -752,22 +780,22 @@ export class CopilotAgent extends Disposable implements IAgent { if (!entry) { return []; } - const rawMessages = await entry.getMessages(); + const rawTurns = await entry.getMessages(); // If a worktree was created for this session at create-time, prepend - // the announcement to the first assistant message's content so it - // appears at the top of the first response when the session is - // reopened. The live path (sendMessage) handles the very first turn - // when the session is fresh; this path takes over on subsequent - // loads, where _pendingFirstTurnAnnouncements is empty. + // the announcement to the first turn so it appears at the top of the + // first response when the session is reopened. The live path + // (sendMessage) handles the very first turn when the session is fresh; + // this path takes over on subsequent loads, where + // _pendingFirstTurnAnnouncements is empty. const branchName = await this._readWorktreeBranchMetadata(session).catch(err => { this._logService.warn(`[Copilot:${sessionId}] Failed to read worktree branch metadata`, err); return undefined; }); if (!branchName) { - return rawMessages; + return rawTurns; } - return [...prependAnnouncementToFirstAssistantMessage(rawMessages, buildWorktreeAnnouncementText(branchName))]; + return prependAnnouncementToFirstTurn(rawTurns, buildWorktreeAnnouncementText(branchName)); } async disposeSession(session: URI): Promise { diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index eec841b7a1d69..d369fa4c2c03f 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -17,14 +17,15 @@ import { INativeEnvironmentService } from '../../../environment/common/environme import { IFileService } from '../../../files/common/files.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; -import { IAgentAttachment, IAgentProgressEvent, SessionHistoryEvent } from '../../common/agentService.js'; +import { AgentSignal, IAgentAttachment } from '../../common/agentService.js'; import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; import type { FileEdit, ToolDefinition } from '../../common/state/protocol/state.js'; -import { SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolResultContentType, type PendingMessage, type SessionInputAnswer, type SessionInputRequest, type ToolCallResult, type ToolResultContent } from '../../common/state/sessionState.js'; +import { ActionType, type SessionAction } from '../../common/state/sessionActions.js'; +import { ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type PendingMessage, type SessionInputAnswer, type SessionInputRequest, type ToolCallResult, type ToolResultContent, type Turn, type URI as ProtocolURI } from '../../common/state/sessionState.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; import type { ShellManager } from './copilotShellTools.js'; -import { getEditFilePath, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellLanguage, getSubagentMetadata, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isShellTool, synthesizeSkillToolEvents, tryStringify, type ITypedPermissionRequest } from './copilotToolDisplay.js'; +import { getEditFilePath, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellLanguage, getSubagentMetadata, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isShellTool, synthesizeSkillToolCall, tryStringify, type ITypedPermissionRequest } from './copilotToolDisplay.js'; import { FileEditTracker } from './fileEditTracker.js'; import { mapSessionEvents } from './mapSessionEvents.js'; import { buildPendingEditContentUri } from './pendingEditContentStore.js'; @@ -80,7 +81,7 @@ export type SessionWrapperFactory = (callbacks: { export interface ICopilotAgentSessionOptions { readonly sessionUri: URI; readonly rawSessionId: string; - readonly onDidSessionProgress: Emitter; + readonly onDidSessionProgress: Emitter; readonly wrapperFactory: SessionWrapperFactory; readonly shellManager: ShellManager | undefined; /** Working directory associated with the session, used to strip redundant `cd` prefixes from shell commands. */ @@ -128,12 +129,22 @@ export class CopilotAgentSession extends Disposable { * is disposed. */ private readonly _pendingEditContentUris = new Map(); - private readonly _onDidSessionProgress: Emitter; + private readonly _onDidSessionProgress: Emitter; private readonly _wrapperFactory: SessionWrapperFactory; private readonly _shellManager: ShellManager | undefined; private readonly _workingDirectory: URI | undefined; private readonly _customizationDirectory: URI | undefined; + /** + * Current markdown response part ID for the active turn. Streaming text + * deltas append to this part; the first delta of a turn allocates a new + * part ID. Reset on each new turn (in {@link send}) and invalidated when + * a tool call begins so subsequent text creates a fresh part. + */ + private _currentMarkdownPartId: string | undefined; + /** Current reasoning response part ID for the active turn. Reset on each new turn. */ + private _currentReasoningPartId: string | undefined; + constructor( options: ICopilotAgentSessionOptions, @IInstantiationService private readonly _instantiationService: IInstantiationService, @@ -179,9 +190,10 @@ export class CopilotAgentSession extends Disposable { title: displayName, }); - this._onDidSessionProgress.fire({ - session: this.sessionUri, - type: 'tool_content_changed', + this._emitAction({ + type: ActionType.SessionToolCallContentChanged, + session: this._protocolSession(), + turnId: this._turnId, toolCallId, content: tracked.content, }); @@ -190,6 +202,95 @@ export class CopilotAgentSession extends Disposable { this._register(toDisposable(() => this._cancelPendingClientToolCalls())); } + // ---- AgentSignal helpers ------------------------------------------------ + + private _protocolSession(): ProtocolURI { + return this.sessionUri.toString(); + } + + /** Wraps a {@link SessionAction} in an {@link AgentSignal} envelope and emits it. */ + private _emitAction(action: SessionAction, parentToolCallId?: string): void { + this._onDidSessionProgress.fire({ + kind: 'action', + session: this.sessionUri, + action, + parentToolCallId, + }); + } + + /** + * Resets per-turn streaming state so the next text/reasoning chunk + * allocates a fresh response part. Called by the agent when a new turn + * starts (typically right before {@link send}). + */ + resetTurnState(turnId: string): void { + this._turnId = turnId; + this._currentMarkdownPartId = undefined; + this._currentReasoningPartId = undefined; + } + + /** + * Emits a synthetic markdown content block for the active turn and + * makes it the current markdown response part so that subsequent SDK + * deltas append to it. Used by the agent to surface one-shot host + * messages (e.g. the worktree-created announcement) at the top of the + * first response. + */ + emitInitialMarkdown(content: string): void { + this._emitMarkdownDelta(content); + } + + /** + * Emits a streaming text delta. The first delta of a turn allocates a + * markdown response part; subsequent deltas append to it. + */ + private _emitMarkdownDelta(content: string, parentToolCallId?: string): void { + const session = this._protocolSession(); + let partId = this._currentMarkdownPartId; + if (!partId) { + partId = generateUuid(); + this._currentMarkdownPartId = partId; + this._emitAction({ + type: ActionType.SessionResponsePart, + session, + turnId: this._turnId, + part: { kind: ResponsePartKind.Markdown, id: partId, content }, + }, parentToolCallId); + return; + } + this._emitAction({ + type: ActionType.SessionDelta, + session, + turnId: this._turnId, + partId, + content, + }, parentToolCallId); + } + + /** Emits a reasoning delta, similar to {@link _emitMarkdownDelta} but for reasoning parts. */ + private _emitReasoningDelta(content: string): void { + const session = this._protocolSession(); + let partId = this._currentReasoningPartId; + if (!partId) { + partId = generateUuid(); + this._currentReasoningPartId = partId; + this._emitAction({ + type: ActionType.SessionResponsePart, + session, + turnId: this._turnId, + part: { kind: ResponsePartKind.Reasoning, id: partId, content }, + }); + return; + } + this._emitAction({ + type: ActionType.SessionReasoning, + session, + turnId: this._turnId, + partId, + content, + }); + } + /** * The snapshot of client contributions captured when this session was * created. Used by the agent to detect when the session is 1stale. @@ -323,8 +424,8 @@ export class CopilotAgentSession extends Disposable { mode: 'immediate', }); this._onDidSessionProgress.fire({ + kind: 'steering_consumed', session: this.sessionUri, - type: 'steering_consumed', id: steeringMessage.id, }); } catch (err) { @@ -332,7 +433,7 @@ export class CopilotAgentSession extends Disposable { } } - async getMessages(): Promise { + async getMessages(): Promise { const events = await this._wrapper.session.getMessages(); let db: ISessionDatabase | undefined; try { @@ -340,7 +441,20 @@ export class CopilotAgentSession extends Disposable { } catch { // Database may not exist yet — that's fine } - return mapSessionEvents(this.sessionUri, db, events, this._workingDirectory); + const result = await mapSessionEvents(this.sessionUri, db, events, this._workingDirectory); + return result.turns; + } + + async getSubagentMessages(parentToolCallId: string, childSessionUri: string): Promise { + const events = await this._wrapper.session.getMessages(); + let db: ISessionDatabase | undefined; + try { + db = this._databaseRef.object; + } catch { + // Database may not exist yet — that's fine + } + const result = await mapSessionEvents(this.sessionUri, db, events, this._workingDirectory); + return result.subagentTurnsByToolCallId.get(parentToolCallId) ?? []; } async abort(): Promise { @@ -413,17 +527,23 @@ export class CopilotAgentSession extends Disposable { return { kind: 'denied-interactively-by-user' }; } - // Fire a tool_ready event to transition the tool to PendingConfirmation + // Fire a pending_confirmation signal to transition the tool to PendingConfirmation + const toolName = request.toolName ?? request.kind; this._onDidSessionProgress.fire({ + kind: 'pending_confirmation', session: this.sessionUri, - type: 'tool_ready', - toolCallId, - invocationMessage, - toolInput, - confirmationTitle, + state: { + status: ToolCallStatus.PendingConfirmation, + toolCallId, + toolName, + displayName: getToolDisplayName(toolName), + invocationMessage, + toolInput, + confirmationTitle, + edits, + }, permissionKind, permissionPath, - edits, }); const approved = await deferred.p; @@ -571,9 +691,9 @@ export class CopilotAgentSession extends Disposable { ], }; - this._onDidSessionProgress.fire({ - session: this.sessionUri, - type: 'user_input_request', + this._emitAction({ + type: ActionType.SessionInputRequested, + session: this._protocolSession(), request: inputRequest, }); @@ -648,7 +768,6 @@ export class CopilotAgentSession extends Disposable { private _subscribeToEvents(): void { const wrapper = this._wrapper; const sessionId = this.sessionId; - const session = this.sessionUri; // Capture SDK event IDs for each user.message event so we can map // protocol turn indices to the event IDs needed by the SDK's @@ -661,34 +780,34 @@ export class CopilotAgentSession extends Disposable { this._register(wrapper.onMessageDelta(e => { this._logService.trace(`[Copilot:${sessionId}] delta: ${e.data.deltaContent}`); - this._onDidSessionProgress.fire({ - session, - type: 'delta', - messageId: e.data.messageId, - content: e.data.deltaContent, - parentToolCallId: e.data.parentToolCallId, - }); + this._emitMarkdownDelta(e.data.deltaContent, e.data.parentToolCallId); })); this._register(wrapper.onMessage(e => { this._logService.info(`[Copilot:${sessionId}] Full message received: ${e.data.content.length} chars`); - this._onDidSessionProgress.fire({ - session, - type: 'message', - role: 'assistant', - messageId: e.data.messageId, - content: e.data.content, - toolRequests: e.data.toolRequests?.map(tr => ({ - toolCallId: tr.toolCallId, - name: tr.name, - arguments: tr.arguments !== undefined ? tryStringify(tr.arguments) : undefined, - type: tr.type, - })), - reasoningOpaque: e.data.reasoningOpaque, - reasoningText: e.data.reasoningText, - encryptedContent: e.data.encryptedContent, - parentToolCallId: e.data.parentToolCallId, - }); + // The SDK fires a `message` event with the full assembled content after + // streaming deltas. If deltas already created a markdown part for this + // turn, the live state is up to date and we skip. Only emit a fresh + // part when no deltas preceded the message (e.g. text after tool calls + // where the SDK delivered the full message at once). + // + // Other fields (toolRequests, reasoningText, encryptedContent) are + // only used for history reconstruction and live tool calls fire + // their own tool_start events, so we can safely drop them here. + if (!e.data.content) { + return; + } + if (this._currentMarkdownPartId) { + return; + } + const partId = generateUuid(); + this._currentMarkdownPartId = partId; + this._emitAction({ + type: ActionType.SessionResponsePart, + session: this._protocolSession(), + turnId: this._turnId, + part: { kind: ResponsePartKind.Markdown, id: partId, content: e.data.content }, + }, e.data.parentToolCallId); })); this._register(wrapper.onToolStart(e => { @@ -712,25 +831,62 @@ export class CopilotAgentSession extends Disposable { this._activeToolCalls.set(e.data.toolCallId, { toolName: e.data.toolName, displayName, parameters, content: [] }); const toolKind = getToolKind(e.data.toolName); const subagentMeta = toolKind === 'subagent' ? getSubagentMetadata(parameters) : undefined; + const toolClientId = this._clientToolNames.has(e.data.toolName) ? this._appliedSnapshot.clientId : undefined; + const parentToolCallId = e.data.parentToolCallId; + + // A new tool call invalidates the current markdown and reasoning + // parts so the next text/reasoning delta after the tool call + // starts a fresh part. Without invalidating reasoning here, a + // later round of reasoning (after tool_start/tool_complete) + // would silently append to the pre-tool-call reasoning block. + this._currentMarkdownPartId = undefined; + this._currentReasoningPartId = undefined; + + const meta: Record = { toolKind, language: toolKind === 'terminal' ? getShellLanguage(e.data.toolName) : undefined }; + if (subagentMeta?.description) { + meta.subagentDescription = subagentMeta.description; + } + if (subagentMeta?.agentName) { + meta.subagentAgentName = subagentMeta.agentName; + } + if (toolArgs !== undefined) { + meta.toolArguments = toolArgs; + } + if (e.data.mcpServerName) { + meta.mcpServerName = e.data.mcpServerName; + } + if (e.data.mcpToolName) { + meta.mcpToolName = e.data.mcpToolName; + } - this._onDidSessionProgress.fire({ - session, - type: 'tool_start', + const protocolSession = this._protocolSession(); + this._emitAction({ + type: ActionType.SessionToolCallStart, + session: protocolSession, + turnId: this._turnId, toolCallId: e.data.toolCallId, toolName: e.data.toolName, displayName, + toolClientId, + _meta: meta, + }, parentToolCallId); + + // For client tools, do NOT auto-ready — the tool handler will fire + // a separate tool_ready signal once the deferred is in place (or + // the permission flow fires it first). + if (toolClientId) { + return; + } + + this._emitAction({ + type: ActionType.SessionToolCallReady, + session: protocolSession, + turnId: this._turnId, + toolCallId: e.data.toolCallId, invocationMessage: getInvocationMessage(e.data.toolName, displayName, parameters), toolInput: getToolInputString(e.data.toolName, parameters, toolArgs), - toolKind, - language: toolKind === 'terminal' ? getShellLanguage(e.data.toolName) : undefined, - toolArguments: toolArgs, - subagentAgentName: subagentMeta?.agentName, - subagentDescription: subagentMeta?.description, - mcpServerName: e.data.mcpServerName, - mcpToolName: e.data.mcpToolName, - parentToolCallId: e.data.parentToolCallId, - toolClientId: this._clientToolNames.has(e.data.toolName) ? this._appliedSnapshot.clientId : undefined, - }); + confirmed: ToolCallConfirmationReason.NotNeeded, + }, parentToolCallId); })); this._register(wrapper.onToolComplete(async e => { @@ -773,9 +929,10 @@ export class CopilotAgentSession extends Disposable { } } - this._onDidSessionProgress.fire({ - session, - type: 'tool_complete', + this._emitAction({ + type: ActionType.SessionToolCallComplete, + session: this._protocolSession(), + turnId: this._turnId, toolCallId: e.data.toolCallId, result: { success: e.data.success, @@ -783,15 +940,16 @@ export class CopilotAgentSession extends Disposable { content: content.length > 0 ? content : undefined, error: e.data.error, }, - isUserRequested: e.data.isUserRequested, - toolTelemetry: e.data.toolTelemetry !== undefined ? tryStringify(e.data.toolTelemetry) : undefined, - parentToolCallId: e.data.parentToolCallId, - }); + }, e.data.parentToolCallId); })); this._register(wrapper.onIdle(() => { this._logService.info(`[Copilot:${sessionId}] Session idle`); - this._onDidSessionProgress.fire({ session, type: 'idle' }); + this._emitAction({ + type: ActionType.SessionTurnComplete, + session: this._protocolSession(), + turnId: this._turnId, + }); })); // The SDK emits a `skill` tool call (which we hide) and a richer @@ -800,16 +958,41 @@ export class CopilotAgentSession extends Disposable { // clickable file link, matching the `view`-tool display style. this._register(wrapper.onSkillInvoked(e => { this._logService.info(`[Copilot:${sessionId}] Skill invoked: ${e.data.name} (${e.data.path})`); - const { start, complete } = synthesizeSkillToolEvents(session, e.data, e.id); - this._onDidSessionProgress.fire(start); - this._onDidSessionProgress.fire(complete); + const synth = synthesizeSkillToolCall(e.data, e.id); + const protocolSession = this._protocolSession(); + this._emitAction({ + type: ActionType.SessionToolCallStart, + session: protocolSession, + turnId: this._turnId, + toolCallId: synth.toolCallId, + toolName: synth.toolName, + displayName: synth.displayName, + }); + this._emitAction({ + type: ActionType.SessionToolCallReady, + session: protocolSession, + turnId: this._turnId, + toolCallId: synth.toolCallId, + invocationMessage: synth.invocationMessage, + confirmed: ToolCallConfirmationReason.NotNeeded, + }); + this._emitAction({ + type: ActionType.SessionToolCallComplete, + session: protocolSession, + turnId: this._turnId, + toolCallId: synth.toolCallId, + result: { + success: true, + pastTenseMessage: synth.pastTenseMessage, + }, + }); })); this._register(wrapper.onSubagentStarted(e => { this._logService.info(`[Copilot:${sessionId}] Subagent started: toolCallId=${e.data.toolCallId}, agent=${e.data.agentName}`); this._onDidSessionProgress.fire({ - session, - type: 'subagent_started', + kind: 'subagent_started', + session: this.sessionUri, toolCallId: e.data.toolCallId, agentName: e.data.agentName, agentDisplayName: e.data.agentDisplayName, @@ -819,34 +1002,36 @@ export class CopilotAgentSession extends Disposable { this._register(wrapper.onSessionError(e => { this._logService.error(`[Copilot:${sessionId}] Session error: ${e.data.errorType} - ${e.data.message}`); - this._onDidSessionProgress.fire({ - session, - type: 'error', - errorType: e.data.errorType, - message: e.data.message, - stack: e.data.stack, + this._emitAction({ + type: ActionType.SessionError, + session: this._protocolSession(), + turnId: this._turnId, + error: { + errorType: e.data.errorType, + message: e.data.message, + stack: e.data.stack, + }, }); })); this._register(wrapper.onUsage(e => { this._logService.trace(`[Copilot:${sessionId}] Usage: model=${e.data.model}, in=${e.data.inputTokens ?? '?'}, out=${e.data.outputTokens ?? '?'}, cacheRead=${e.data.cacheReadTokens ?? '?'}`); - this._onDidSessionProgress.fire({ - session, - type: 'usage', - inputTokens: e.data.inputTokens, - outputTokens: e.data.outputTokens, - model: e.data.model, - cacheReadTokens: e.data.cacheReadTokens, + this._emitAction({ + type: ActionType.SessionUsage, + session: this._protocolSession(), + turnId: this._turnId, + usage: { + inputTokens: e.data.inputTokens, + outputTokens: e.data.outputTokens, + model: e.data.model, + cacheReadTokens: e.data.cacheReadTokens, + }, }); })); this._register(wrapper.onReasoningDelta(e => { this._logService.trace(`[Copilot:${sessionId}] Reasoning delta: ${e.data.deltaContent.length} chars`); - this._onDidSessionProgress.fire({ - session, - type: 'reasoning', - content: e.data.deltaContent, - }); + this._emitReasoningDelta(e.data.deltaContent); })); } diff --git a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts index 2d061529b7601..f60915b393bd1 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts @@ -9,7 +9,7 @@ import { URI } from '../../../../base/common/uri.js'; import { appendEscapedMarkdownInlineCode, escapeMarkdownLinkLabel } from '../../../../base/common/htmlContent.js'; import { hash } from '../../../../base/common/hash.js'; import { localize } from '../../../../nls.js'; -import type { IAgentToolCompleteEvent, IAgentToolReadyEvent, IAgentToolStartEvent } from '../../common/agentService.js'; +import type { IAgentToolPendingConfirmationSignal } from '../../common/agentService.js'; import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; import { StringOrMarkdown } from '../../common/state/protocol/state.js'; import { basename } from '../../../../base/common/resources.js'; @@ -177,7 +177,7 @@ const SUBAGENT_TOOL_NAMES: ReadonlySet = new Set([ * lifecycle event with the resolved skill file path; the agent session * synthesizes a tool-start/complete pair from that event so the UI can * render a clickable file link instead of just the skill name. See - * {@link synthesizeSkillToolEvents}. + * {@link synthesizeSkillToolCall}. */ const HIDDEN_TOOL_NAMES: ReadonlySet = new Set([ CopilotToolName.ReportIntent, @@ -442,16 +442,29 @@ export function getSkillSyntheticToolCallId(eventId: string | undefined, data: I } /** - * Synthesizes the `tool_start` and `tool_complete` agent progress events that - * represent a successful `skill.invoked` lifecycle event. Used by both the - * live session handler and the history-replay mapper so the two paths render - * identically. + * Synthesized data for a `skill.invoked` tool call. Used by both the live + * session handler and the history-replay mapper so the two paths render + * identically. Callers wrap this into protocol actions or {@link Turn} + * data; this helper avoids any agent-protocol coupling. */ -export function synthesizeSkillToolEvents( - session: URI, +export interface ISynthesizedSkillToolCall { + readonly toolCallId: string; + readonly toolName: string; + readonly displayName: string; + readonly invocationMessage: StringOrMarkdown; + readonly pastTenseMessage: StringOrMarkdown; +} + +/** + * Synthesizes the data for a `skill.invoked` tool call (a tool-start / + * tool-complete pair). Returns the constituent fields without coupling to + * any specific event or action shape — callers compose them into protocol + * actions or {@link Turn} entries as needed. + */ +export function synthesizeSkillToolCall( data: ICopilotSkillInvokedData, eventId: string | undefined, -): { start: IAgentToolStartEvent; complete: IAgentToolCompleteEvent } { +): ISynthesizedSkillToolCall { const toolCallId = getSkillSyntheticToolCallId(eventId, data); const displayName = localize('toolName.skill', "Read Skill"); // Use the skill name as the link text rather than the basename: every skill @@ -472,24 +485,13 @@ export function synthesizeSkillToolEvents( const pastTenseMessage: StringOrMarkdown = skillLink ? md(localize('toolComplete.skill', "Read skill {0}", skillLink)) : localize('toolComplete.skillName', "Read skill {0}", data.name); - const start: IAgentToolStartEvent = { - session, - type: 'tool_start', + return { toolCallId, toolName: CopilotToolName.Skill, displayName, invocationMessage, + pastTenseMessage, }; - const complete: IAgentToolCompleteEvent = { - session, - type: 'tool_complete', - toolCallId, - result: { - success: true, - pastTenseMessage, - }, - }; - return { start, complete }; } export function getToolInputString(toolName: string, parameters: Record | undefined, rawArguments: string | undefined): string | undefined { @@ -635,7 +637,7 @@ export function getPermissionDisplay(request: ITypedPermissionRequest, workingDi invocationMessage: StringOrMarkdown; toolInput?: string; /** Normalized permission kind for auto-approval routing. */ - permissionKind: IAgentToolReadyEvent['permissionKind']; + permissionKind: IAgentToolPendingConfirmationSignal['permissionKind']; /** File path extracted from the request. */ permissionPath?: string; } { diff --git a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts index 5a306612eb492..269d7689b0e16 100644 --- a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts @@ -4,11 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from '../../../../base/common/uri.js'; -import { SessionHistoryEvent } from '../../common/agentService.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; import { IFileEditRecord, ISessionDatabase } from '../../common/sessionDataService.js'; -import { ToolResultContentType, type ToolResultContent } from '../../common/state/sessionState.js'; -import { getInvocationMessage, getPastTenseMessage, getShellLanguage, getSubagentMetadata, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, synthesizeSkillToolEvents } from './copilotToolDisplay.js'; +import { ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, type ResponsePart, type StringOrMarkdown, type ToolCallCompletedState, type ToolResultContent, type Turn } from '../../common/state/sessionState.js'; +import { getInvocationMessage, getPastTenseMessage, getShellLanguage, getSubagentMetadata, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, synthesizeSkillToolCall } from './copilotToolDisplay.js'; import { buildSessionDbUri } from './fileEditTracker.js'; function tryStringify(value: unknown): string | undefined { @@ -79,6 +79,25 @@ export interface ISessionEventSkillInvoked { }; } +export interface ISessionEventSubagentStarted { + type: 'subagent.started'; + data: { + toolCallId: string; + agentName: string; + agentDisplayName: string; + agentDescription: string; + }; +} + +/** Minimal event shape for session history mapping. */ +export type ISessionEvent = + | ISessionEventToolStart + | ISessionEventToolComplete + | ISessionEventMessage + | ISessionEventSubagentStarted + | ISessionEventSkillInvoked + | { type: string; data?: unknown }; + /** * Returns true if the event is a SDK-injected `user.message` that should not * be shown to the user (e.g. skill-content injection). @@ -87,7 +106,7 @@ export interface ISessionEventSkillInvoked { * persisted before `source` existed will not be filtered; that is accepted * leakage rather than guessed-at content sniffing. */ -export function isSyntheticUserMessage(event: ISessionEvent): boolean { +function isSyntheticUserMessage(event: ISessionEvent): boolean { if (event.type !== 'user.message') { return false; } @@ -95,63 +114,123 @@ export function isSyntheticUserMessage(event: ISessionEvent): boolean { return !!source && source.toLowerCase() !== 'user'; } -/** Minimal event shape for session history mapping. */ -export type ISessionEvent = ISessionEventToolStart | ISessionEventToolComplete | ISessionEventMessage | ISessionEventSubagentStarted | ISessionEventSkillInvoked | { type: string; data?: unknown }; +// ============================================================================= +// Single-pass turn builder +// ============================================================================= -export interface ISessionEventSubagentStarted { - type: 'subagent.started'; - data: { - toolCallId: string; - agentName: string; - agentDisplayName: string; - agentDescription: string; +/** Per-tool-call info captured from `tool.execution_start` and reused at `tool.execution_complete`. */ +interface IToolStartInfo { + readonly toolName: string; + readonly displayName: string; + readonly invocationMessage: StringOrMarkdown; + readonly toolInput?: string; + readonly toolKind?: 'terminal' | 'subagent'; + readonly language?: string; + readonly subagentAgentName?: string; + readonly subagentDescription?: string; + readonly parameters: Record | undefined; + readonly parentToolCallId?: string; +} + +/** Subagent metadata seen via `subagent.started`, applied to the parent tool call's content at `tool.execution_complete`. */ +interface ISubagentInfo { + readonly agentName: string; + readonly agentDisplayName: string; + readonly agentDescription?: string; +} + +/** + * Mutable per-turn state used while iterating events. The parent session + * has one builder; each subagent turn (one per `parentToolCallId`) has its + * own builder so inner events route there directly. + */ +interface ITurnBuilder { + readonly id: string; + readonly userMessage: { text: string }; + readonly responseParts: ResponsePart[]; + /** Tool starts seen but not yet completed in this turn, keyed by toolCallId. */ + readonly pendingTools: Map; +} + +function newTurnBuilder(id: string, text: string): ITurnBuilder { + return { id, userMessage: { text }, responseParts: [], pendingTools: new Map() }; +} + +function finalizeTurn(builder: ITurnBuilder, state: TurnState): Turn { + return { + id: builder.id, + userMessage: builder.userMessage, + responseParts: builder.responseParts, + usage: undefined, + state, }; } /** - * Maps raw SDK session events into agent protocol events, restoring - * stored file-edit metadata from the session database when available. + * Maps raw SDK session events directly into agent-protocol {@link Turn}s + * for the parent session and any subagent child sessions, restoring stored + * file-edit metadata from the session database when available. + * + * Subagent inner events are routed to per-`parentToolCallId` turn builders + * so they appear under their own session view rather than polluting the + * parent transcript. Each subagent's tool calls are returned via + * {@link mapSessionEventsToTurns.subagentTurnsByToolCallId} so callers can + * expose `getSubagentMessages` cheaply. * * If `workingDirectory` is provided, redundant `cd &&` * (or PowerShell equivalent) prefixes are stripped from shell tool * commands so clients see the simplified form. - * - * Extracted as a standalone function so it can be tested without the - * full CopilotAgent or SDK dependencies. */ export async function mapSessionEvents( session: URI, db: ISessionDatabase | undefined, events: readonly ISessionEvent[], workingDirectory?: URI, -): Promise { - const result: SessionHistoryEvent[] = []; - const toolInfoByCallId = new Map | undefined; rewrittenArgs?: string }>(); - - // Collect all tool call IDs for edit tools so we can batch-query the database +): Promise<{ turns: Turn[]; subagentTurnsByToolCallId: ReadonlyMap }> { + // First pass: collect tool-arg info and identify edit tool calls so we + // can batch-load their stored file edits before the second pass needs + // them at `tool.execution_complete` time. + const toolInfoByCallId = new Map(); const editToolCallIds: string[] = []; - - // First pass: collect tool info and identify edit tool calls for (const e of events) { - if (e.type === 'tool.execution_start') { - const d = (e as ISessionEventToolStart).data; - if (isHiddenTool(d.toolName)) { - continue; - } - const toolArgs = d.arguments !== undefined ? tryStringify(d.arguments) : undefined; - let parameters: Record | undefined; - if (toolArgs) { - try { parameters = JSON.parse(toolArgs) as Record; } catch { /* ignore */ } - } - const rewrittenArgs = stripRedundantCdPrefix(d.toolName, parameters, workingDirectory) ? tryStringify(parameters) : undefined; - toolInfoByCallId.set(d.toolCallId, { toolName: d.toolName, parameters, rewrittenArgs }); - if (isEditTool(d.toolName)) { - editToolCallIds.push(d.toolCallId); - } + if (e.type !== 'tool.execution_start') { + continue; + } + const d = (e as ISessionEventToolStart).data; + if (isHiddenTool(d.toolName)) { + continue; + } + const rawArgs = d.arguments !== undefined ? tryStringify(d.arguments) : undefined; + let parameters: Record | undefined; + if (rawArgs) { + try { parameters = JSON.parse(rawArgs) as Record; } catch { /* ignore */ } + } + // stripRedundantCdPrefix mutates `parameters` and signals via its + // return value. We re-stringify only when it changed something so + // `getToolInputString` sees the cleaned command line. + const cleaned = stripRedundantCdPrefix(d.toolName, parameters, workingDirectory) ? tryStringify(parameters) : undefined; + const toolArgs = cleaned ?? rawArgs; + const toolKind = getToolKind(d.toolName); + const subagentMeta = toolKind === 'subagent' ? getSubagentMetadata(parameters) : undefined; + const displayName = getToolDisplayName(d.toolName); + toolInfoByCallId.set(d.toolCallId, { + toolName: d.toolName, + displayName, + invocationMessage: getInvocationMessage(d.toolName, displayName, parameters), + toolInput: getToolInputString(d.toolName, parameters, toolArgs), + toolKind, + language: toolKind === 'terminal' ? getShellLanguage(d.toolName) : undefined, + subagentAgentName: subagentMeta?.agentName, + subagentDescription: subagentMeta?.description, + parameters, + parentToolCallId: d.parentToolCallId, + }); + if (isEditTool(d.toolName)) { + editToolCallIds.push(d.toolCallId); } } - // Query the database for stored file edits (metadata only) + // Pre-load stored file-edit metadata for all edit tool calls. let storedEdits: Map | undefined; if (db && editToolCallIds.length > 0) { try { @@ -167,134 +246,262 @@ export async function mapSessionEvents( list.push(r); } } - } catch (_e) { - // Database may not exist yet for new sessions — that's fine + } catch { + // Database may not exist yet for new sessions — that's fine. } } const sessionUriStr = session.toString(); + const turns: Turn[] = []; + + // Subagent state. Each subagent has its own active turn builder; only + // the most recent turn per subagent is built (subagents currently emit + // at most one turn per invocation). + const subagentBuilders = new Map(); + const subagentTurns = new Map(); + const subagentInfoByToolCallId = new Map(); + + let parentBuilder: ITurnBuilder | undefined; + + const flushSubagent = (parentToolCallId: string): void => { + const builder = subagentBuilders.get(parentToolCallId); + if (!builder) { + return; + } + subagentBuilders.delete(parentToolCallId); + if (builder.responseParts.length === 0) { + return; + } + const list = subagentTurns.get(parentToolCallId) ?? []; + list.push(finalizeTurn(builder, TurnState.Complete)); + subagentTurns.set(parentToolCallId, list); + }; + + const ensureSubagentBuilder = (parentToolCallId: string): ITurnBuilder => { + let builder = subagentBuilders.get(parentToolCallId); + if (!builder) { + builder = newTurnBuilder(generateUuid(), ''); + subagentBuilders.set(parentToolCallId, builder); + } + return builder; + }; + + const targetBuilderFor = (parentToolCallId: string | undefined): ITurnBuilder | undefined => { + if (parentToolCallId) { + return ensureSubagentBuilder(parentToolCallId); + } + return parentBuilder; + }; - // Second pass: build result events for (const e of events) { - if (e.type === 'assistant.message' || e.type === 'user.message') { - if (isSyntheticUserMessage(e)) { - continue; + switch (e.type) { + case 'user.message': { + if (isSyntheticUserMessage(e)) { + continue; + } + const d = (e as ISessionEventMessage).data; + const messageId = d?.messageId ?? d?.interactionId ?? ''; + const content = d?.content ?? ''; + if (d?.parentToolCallId) { + // User messages with a parent tool call route into the + // subagent's transcript. They never start a new parent + // turn; subagents currently only see assistant messages + // in practice, but route conservatively. + const builder = ensureSubagentBuilder(d.parentToolCallId); + if (content) { + builder.responseParts.push({ + kind: ResponsePartKind.Markdown, + id: generateUuid(), + content, + }); + } + } else { + // A new top-level user message starts a new parent turn. + if (parentBuilder) { + turns.push(finalizeTurn(parentBuilder, TurnState.Cancelled)); + } + parentBuilder = newTurnBuilder(messageId, content); + } + break; } - const d = (e as ISessionEventMessage).data; - result.push({ - session, - type: 'message', - role: e.type === 'user.message' ? 'user' : 'assistant', - messageId: d?.messageId ?? d?.interactionId ?? '', - content: d?.content ?? '', - toolRequests: d?.toolRequests?.map((tr) => ({ - toolCallId: tr.toolCallId, - name: tr.name, - arguments: tr.arguments !== undefined ? tryStringify(tr.arguments) : undefined, - type: tr.type, - })), - reasoningOpaque: d?.reasoningOpaque, - reasoningText: d?.reasoningText, - encryptedContent: d?.encryptedContent, - parentToolCallId: d?.parentToolCallId, - }); - } else if (e.type === 'tool.execution_start') { - const d = (e as ISessionEventToolStart).data; - if (isHiddenTool(d.toolName)) { - continue; + case 'assistant.message': { + const d = (e as ISessionEventMessage).data; + const messageId = d?.messageId ?? d?.interactionId ?? ''; + const content = d?.content ?? ''; + const reasoningText = d?.reasoningText; + const hasToolRequests = !!d?.toolRequests && d.toolRequests.length > 0; + const builder = targetBuilderFor(d?.parentToolCallId) + ?? (parentBuilder = newTurnBuilder(messageId, '')); + if (reasoningText) { + builder.responseParts.push({ + kind: ResponsePartKind.Reasoning, + id: generateUuid(), + content: reasoningText, + }); + } + if (content) { + builder.responseParts.push({ + kind: ResponsePartKind.Markdown, + id: generateUuid(), + content, + }); + } + // A parent assistant message without further tool requests + // terminates the current parent turn (no more responses + // expected). Subagent turns are flushed at the parent's + // `tool.execution_complete` instead. + if (!d?.parentToolCallId && !hasToolRequests && builder === parentBuilder) { + turns.push(finalizeTurn(parentBuilder, TurnState.Complete)); + parentBuilder = undefined; + } + break; } - const info = toolInfoByCallId.get(d.toolCallId); - const displayName = getToolDisplayName(d.toolName); - const toolKind = getToolKind(d.toolName); - const toolArgs = info?.rewrittenArgs ?? (d.arguments !== undefined ? tryStringify(d.arguments) : undefined); - const subagentMeta = toolKind === 'subagent' ? getSubagentMetadata(info?.parameters) : undefined; - result.push({ - session, - type: 'tool_start', - toolCallId: d.toolCallId, - toolName: d.toolName, - displayName, - invocationMessage: getInvocationMessage(d.toolName, displayName, info?.parameters), - toolInput: getToolInputString(d.toolName, info?.parameters, toolArgs), - toolKind, - language: toolKind === 'terminal' ? getShellLanguage(d.toolName) : undefined, - toolArguments: toolArgs, - subagentAgentName: subagentMeta?.agentName, - subagentDescription: subagentMeta?.description, - mcpServerName: d.mcpServerName, - mcpToolName: d.mcpToolName, - parentToolCallId: d.parentToolCallId, - }); - } else if (e.type === 'tool.execution_complete') { - const d = (e as ISessionEventToolComplete).data; - const info = toolInfoByCallId.get(d.toolCallId); - if (!info) { - continue; + case 'subagent.started': { + const d = (e as ISessionEventSubagentStarted).data; + subagentInfoByToolCallId.set(d.toolCallId, { + agentName: d.agentName, + agentDisplayName: d.agentDisplayName, + agentDescription: d.agentDescription, + }); + break; } - toolInfoByCallId.delete(d.toolCallId); - const displayName = getToolDisplayName(info.toolName); - const toolOutput = d.error?.message ?? d.result?.content; - const content: ToolResultContent[] = []; - if (toolOutput !== undefined) { - content.push({ type: ToolResultContentType.Text, text: toolOutput }); + case 'tool.execution_start': { + // Already collected in the first pass; no per-event work + // needed here. Hidden tools are filtered above. + break; } - - // Restore file edit content references from the database - const edits = storedEdits?.get(d.toolCallId); - if (edits) { - for (const edit of edits) { - const beforeUri = edit.kind === 'rename' && edit.originalPath - ? URI.file(edit.originalPath).toString() - : URI.file(edit.filePath).toString(); - const afterUri = URI.file(edit.filePath).toString(); - const hasBefore = edit.kind !== 'create'; - const hasAfter = edit.kind !== 'delete'; - content.push({ - type: ToolResultContentType.FileEdit, - before: hasBefore ? { - uri: beforeUri, - content: { uri: buildSessionDbUri(sessionUriStr, edit.toolCallId, edit.filePath, 'before') }, - } : undefined, - after: hasAfter ? { - uri: afterUri, - content: { uri: buildSessionDbUri(sessionUriStr, edit.toolCallId, edit.filePath, 'after') }, - } : undefined, - diff: (edit.addedLines !== undefined || edit.removedLines !== undefined) - ? { added: edit.addedLines, removed: edit.removedLines } - : undefined, - }); + case 'tool.execution_complete': { + const d = (e as ISessionEventToolComplete).data; + const info = toolInfoByCallId.get(d.toolCallId); + if (!info) { + // Orphan complete (no matching start), or hidden tool. + continue; + } + toolInfoByCallId.delete(d.toolCallId); + const builder = targetBuilderFor(d.parentToolCallId); + if (!builder) { + // No active turn to attach this completion to. + continue; } + const completedPart = makeCompletedToolCallPart(d, info, sessionUriStr, storedEdits, subagentInfoByToolCallId.get(d.toolCallId)); + builder.responseParts.push(completedPart); + // When a parent tool call that spawned a subagent completes, + // flush the subagent's accumulated turn. + if (!d.parentToolCallId && subagentInfoByToolCallId.has(d.toolCallId)) { + flushSubagent(d.toolCallId); + } + break; + } + case 'skill.invoked': { + const skill = (e as ISessionEventSkillInvoked); + const synth = synthesizeSkillToolCall(skill.data, skill.id); + const builder = parentBuilder ?? (parentBuilder = newTurnBuilder(generateUuid(), '')); + builder.responseParts.push({ + kind: ResponsePartKind.ToolCall, + toolCall: { + status: ToolCallStatus.Completed, + toolCallId: synth.toolCallId, + toolName: synth.toolName, + displayName: synth.displayName, + invocationMessage: synth.invocationMessage, + success: true, + pastTenseMessage: synth.pastTenseMessage, + confirmed: ToolCallConfirmationReason.NotNeeded, + } satisfies ToolCallCompletedState, + }); + break; } + default: + break; + } + } - result.push({ - session, - type: 'tool_complete', - toolCallId: d.toolCallId, - result: { - success: d.success, - pastTenseMessage: getPastTenseMessage(info.toolName, displayName, info.parameters, d.success), - content: content.length > 0 ? content : undefined, - error: d.error, - }, - isUserRequested: d.isUserRequested, - toolTelemetry: d.toolTelemetry !== undefined ? tryStringify(d.toolTelemetry) : undefined, - parentToolCallId: d.parentToolCallId, - }); - } else if (e.type === 'subagent.started') { - const d = (e as ISessionEventSubagentStarted).data; - result.push({ - session, - type: 'subagent_started', - toolCallId: d.toolCallId, - agentName: d.agentName, - agentDisplayName: d.agentDisplayName, - agentDescription: d.agentDescription, + // Drain any unfinished turns. + if (parentBuilder) { + turns.push(finalizeTurn(parentBuilder, TurnState.Cancelled)); + parentBuilder = undefined; + } + for (const parentToolCallId of [...subagentBuilders.keys()]) { + flushSubagent(parentToolCallId); + } + + return { turns, subagentTurnsByToolCallId: subagentTurns }; +} + +/** + * Builds a {@link ToolCallCompletedState}-shaped response part from an + * SDK `tool.execution_complete` event. Restores file-edit content + * references from `storedEdits` and merges subagent metadata when the + * tool call spawned a child session. + */ +function makeCompletedToolCallPart( + d: ISessionEventToolComplete['data'], + info: IToolStartInfo, + sessionUriStr: string, + storedEdits: Map | undefined, + subagent: ISubagentInfo | undefined, +): ResponsePart { + const toolOutput = d.error?.message ?? d.result?.content; + const content: ToolResultContent[] = []; + if (toolOutput !== undefined) { + content.push({ type: ToolResultContentType.Text, text: toolOutput }); + } + + // Restore file edit content references from the database. + const edits = storedEdits?.get(d.toolCallId); + if (edits) { + for (const edit of edits) { + const beforeUri = edit.kind === 'rename' && edit.originalPath + ? URI.file(edit.originalPath).toString() + : URI.file(edit.filePath).toString(); + const afterUri = URI.file(edit.filePath).toString(); + const hasBefore = edit.kind !== 'create'; + const hasAfter = edit.kind !== 'delete'; + content.push({ + type: ToolResultContentType.FileEdit, + before: hasBefore ? { + uri: beforeUri, + content: { uri: buildSessionDbUri(sessionUriStr, edit.toolCallId, edit.filePath, 'before') }, + } : undefined, + after: hasAfter ? { + uri: afterUri, + content: { uri: buildSessionDbUri(sessionUriStr, edit.toolCallId, edit.filePath, 'after') }, + } : undefined, + diff: (edit.addedLines !== undefined || edit.removedLines !== undefined) + ? { added: edit.addedLines, removed: edit.removedLines } + : undefined, }); - } else if (e.type === 'skill.invoked') { - const skillEvent = e as ISessionEventSkillInvoked; - const { start, complete } = synthesizeSkillToolEvents(session, skillEvent.data, skillEvent.id); - result.push(start, complete); } } - return result; + + if (subagent) { + content.push({ + type: ToolResultContentType.Subagent, + resource: buildSubagentSessionUri(sessionUriStr, d.toolCallId), + title: subagent.agentDisplayName, + agentName: subagent.agentName, + description: subagent.agentDescription, + }); + } + + const tc: ToolCallCompletedState = { + status: ToolCallStatus.Completed, + toolCallId: d.toolCallId, + toolName: info.toolName, + displayName: info.displayName, + invocationMessage: info.invocationMessage, + toolInput: info.toolInput, + success: d.success, + pastTenseMessage: getPastTenseMessage(info.toolName, info.displayName, info.parameters, d.success), + content: content.length > 0 ? content : undefined, + error: d.error, + confirmed: ToolCallConfirmationReason.NotNeeded, + _meta: { + toolKind: info.toolKind, + language: info.language, + subagentDescription: info.subagentDescription, + subagentAgentName: info.subagentAgentName, + }, + }; + return { kind: ResponsePartKind.ToolCall, toolCall: tc }; } diff --git a/src/vs/platform/agentHost/node/sessionPermissions.ts b/src/vs/platform/agentHost/node/sessionPermissions.ts index 304fc5fa8b430..660181bff2589 100644 --- a/src/vs/platform/agentHost/node/sessionPermissions.ts +++ b/src/vs/platform/agentHost/node/sessionPermissions.ts @@ -9,7 +9,7 @@ import { extUriBiasedIgnorePathCase, normalizePath } from '../../../base/common/ import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { ILogService } from '../../log/common/log.js'; -import type { IAgentToolReadyEvent } from '../common/agentService.js'; +import type { IAgentToolPendingConfirmationSignal } from '../common/agentService.js'; import { platformSessionSchema } from '../common/agentHostSchema.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; import { ConfirmationOptionKind, type ConfirmationOption } from '../common/state/protocol/state.js'; @@ -25,13 +25,13 @@ import { CommandAutoApprover } from './commandAutoApprover.js'; /** * Event fields needed for auto-approval decisions. - * Matches the subset of {@link IAgentToolReadyEvent} used by the + * Matches the subset of {@link IAgentToolPendingConfirmationSignal} used by the * approval pipeline. */ export interface IToolApprovalEvent { readonly toolCallId: string; readonly session: URI; - readonly permissionKind?: IAgentToolReadyEvent['permissionKind']; + readonly permissionKind?: IAgentToolPendingConfirmationSignal['permissionKind']; readonly permissionPath?: string; readonly toolInput?: string; } @@ -167,22 +167,24 @@ export class SessionPermissionManager extends Disposable { // ---- Action construction (analogous to getPreConfirmActions) ------------- /** - * Constructs a `SessionToolCallReady` action from an agent `tool_ready` - * event. When the tool needs user confirmation (`confirmationTitle` is - * set), the standard confirmation options are included in the action so - * clients can render them directly. + * Constructs a `SessionToolCallReady` action from an agent + * `pending_confirmation` signal. When the tool needs user confirmation + * (the protocol state carries `confirmationTitle`), the standard + * confirmation options are baked in so clients can render them directly. */ - createToolReadyAction(e: IAgentToolReadyEvent, sessionKey: ProtocolURI, turnId: string): IToolCallReadyAction { - if (e.confirmationTitle) { + createToolReadyAction(e: IAgentToolPendingConfirmationSignal, sessionKey: ProtocolURI, turnId: string): IToolCallReadyAction { + const state = e.state; + if (state.confirmationTitle) { return { type: ActionType.SessionToolCallReady, session: sessionKey, turnId, - toolCallId: e.toolCallId, - invocationMessage: e.invocationMessage, - toolInput: e.toolInput, - confirmationTitle: e.confirmationTitle, - edits: e.edits, + toolCallId: state.toolCallId, + invocationMessage: state.invocationMessage, + toolInput: state.toolInput, + confirmationTitle: state.confirmationTitle, + edits: state.edits, + editable: state.editable, options: CONFIRMATION_OPTIONS.slice(), }; } @@ -190,9 +192,9 @@ export class SessionPermissionManager extends Disposable { type: ActionType.SessionToolCallReady, session: sessionKey, turnId, - toolCallId: e.toolCallId, - invocationMessage: e.invocationMessage, - toolInput: e.toolInput, + toolCallId: state.toolCallId, + invocationMessage: state.invocationMessage, + toolInput: state.toolInput, confirmed: ToolCallConfirmationReason.NotNeeded, }; } diff --git a/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts b/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts deleted file mode 100644 index e86ceef4c5b82..0000000000000 --- a/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts +++ /dev/null @@ -1,482 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { URI } from '../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import type { - IAgentDeltaEvent, - IAgentErrorEvent, - IAgentIdleEvent, - IAgentMessageEvent, - IAgentReasoningEvent, - IAgentTitleChangedEvent, - IAgentToolCompleteEvent, - IAgentToolReadyEvent, - IAgentToolStartEvent, - IAgentUsageEvent, - IAgentUserInputRequestEvent, -} from '../../common/agentService.js'; -import type { - IDeltaAction, - IReasoningAction, - IResponsePartAction, - SessionAction, - SessionErrorAction, - SessionInputRequestedAction, - ITitleChangedAction, - IToolCallCompleteAction, - IToolCallReadyAction, - IToolCallStartAction, - ITurnCompleteAction, - IUsageAction, -} from '../../common/state/sessionActions.js'; -import { SessionInputQuestionKind, ToolCallConfirmationReason, ToolResultContentType, type MarkdownResponsePart, type ReasoningResponsePart, type SessionInputRequest } from '../../common/state/sessionState.js'; -import { AgentEventMapper } from '../../node/agentEventMapper.js'; - -/** Helper: flatten the result of mapProgressEventToActions into an array. */ -function mapToArray(result: SessionAction | SessionAction[] | undefined): SessionAction[] { - if (!result) { - return []; - } - return Array.isArray(result) ? result : [result]; -} - -suite('AgentEventMapper', () => { - - const session = URI.from({ scheme: 'copilot', path: '/test-session' }); - const turnId = 'turn-1'; - let mapper: AgentEventMapper; - - setup(() => { - mapper = new AgentEventMapper(); - }); - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('first delta event creates a responsePart with content', () => { - const event: IAgentDeltaEvent = { - session, - type: 'delta', - messageId: 'msg-1', - content: 'hello world', - }; - - const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId)); - assert.strictEqual(actions.length, 1); - assert.strictEqual(actions[0].type, 'session/responsePart'); - const part = (actions[0] as IResponsePartAction).part; - assert.strictEqual(part.kind, 'markdown'); - assert.strictEqual(part.content, 'hello world'); - assert.ok(part.id); - }); - - test('subsequent delta event maps to session/delta action', () => { - const first: IAgentDeltaEvent = { session, type: 'delta', messageId: 'msg-1', content: 'hello ' }; - const second: IAgentDeltaEvent = { session, type: 'delta', messageId: 'msg-1', content: 'world' }; - - const firstActions = mapToArray(mapper.mapProgressEventToActions(first, session.toString(), turnId)); - const partId = ((firstActions[0] as IResponsePartAction).part as MarkdownResponsePart).id; - - const secondActions = mapToArray(mapper.mapProgressEventToActions(second, session.toString(), turnId)); - assert.strictEqual(secondActions.length, 1); - const delta = secondActions[0] as IDeltaAction; - assert.strictEqual(delta.type, 'session/delta'); - assert.strictEqual(delta.content, 'world'); - assert.strictEqual(delta.partId, partId); - }); - - test('tool_start event maps to toolCallStart + toolCallReady actions', () => { - const event: IAgentToolStartEvent = { - session, - type: 'tool_start', - toolCallId: 'tc-1', - toolName: 'readFile', - displayName: 'Read File', - invocationMessage: 'Reading file...', - toolInput: '/src/foo.ts', - toolKind: 'terminal', - language: 'shellscript', - }; - - const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId)); - assert.strictEqual(actions.length, 2); - - const startAction = actions[0] as IToolCallStartAction; - assert.strictEqual(startAction.type, 'session/toolCallStart'); - assert.strictEqual(startAction.toolCallId, 'tc-1'); - assert.strictEqual(startAction.toolName, 'readFile'); - assert.strictEqual(startAction.displayName, 'Read File'); - assert.strictEqual(startAction._meta?.toolKind, 'terminal'); - assert.strictEqual(startAction._meta?.language, 'shellscript'); - - const readyAction = actions[1] as IToolCallReadyAction; - assert.strictEqual(readyAction.type, 'session/toolCallReady'); - assert.strictEqual(readyAction.toolCallId, 'tc-1'); - assert.strictEqual(readyAction.invocationMessage, 'Reading file...'); - assert.strictEqual(readyAction.toolInput, '/src/foo.ts'); - assert.strictEqual(readyAction.confirmed, 'not-needed'); - }); - - test('tool_complete event maps to session/toolCallComplete action', () => { - const event: IAgentToolCompleteEvent = { - session, - type: 'tool_complete', - toolCallId: 'tc-1', - result: { - success: true, - pastTenseMessage: 'Read file successfully', - content: [{ type: ToolResultContentType.Text, text: 'file contents here' }], - }, - }; - - const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId)); - assert.strictEqual(actions.length, 1); - const complete = actions[0] as IToolCallCompleteAction; - assert.strictEqual(complete.type, 'session/toolCallComplete'); - assert.strictEqual(complete.toolCallId, 'tc-1'); - assert.strictEqual(complete.result.success, true); - assert.strictEqual(complete.result.pastTenseMessage, 'Read file successfully'); - assert.deepStrictEqual(complete.result.content, [{ type: 'text', text: 'file contents here' }]); - }); - - test('idle event maps to session/turnComplete action', () => { - const event: IAgentIdleEvent = { - session, - type: 'idle', - }; - - const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId)); - assert.strictEqual(actions.length, 1); - const turnComplete = actions[0] as ITurnCompleteAction; - assert.strictEqual(turnComplete.type, 'session/turnComplete'); - assert.strictEqual(turnComplete.session.toString(), session.toString()); - assert.strictEqual(turnComplete.turnId, turnId); - }); - - test('error event maps to session/error action', () => { - const event: IAgentErrorEvent = { - session, - type: 'error', - errorType: 'runtime', - message: 'Something went wrong', - stack: 'Error: Something went wrong\n at foo.ts:1', - }; - - const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId)); - assert.strictEqual(actions.length, 1); - const errorAction = actions[0] as SessionErrorAction; - assert.strictEqual(errorAction.type, 'session/error'); - assert.strictEqual(errorAction.error.errorType, 'runtime'); - assert.strictEqual(errorAction.error.message, 'Something went wrong'); - assert.strictEqual(errorAction.error.stack, 'Error: Something went wrong\n at foo.ts:1'); - }); - - test('usage event maps to session/usage action', () => { - const event: IAgentUsageEvent = { - session, - type: 'usage', - inputTokens: 100, - outputTokens: 50, - model: 'gpt-4', - cacheReadTokens: 25, - }; - - const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId)); - assert.strictEqual(actions.length, 1); - const usageAction = actions[0] as IUsageAction; - assert.strictEqual(usageAction.type, 'session/usage'); - assert.strictEqual(usageAction.usage.inputTokens, 100); - assert.strictEqual(usageAction.usage.outputTokens, 50); - assert.strictEqual(usageAction.usage.model, 'gpt-4'); - assert.strictEqual(usageAction.usage.cacheReadTokens, 25); - }); - - test('title_changed event maps to session/titleChanged action', () => { - const event: IAgentTitleChangedEvent = { - session, - type: 'title_changed', - title: 'New Title', - }; - - const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId)); - assert.strictEqual(actions.length, 1); - assert.strictEqual(actions[0].type, 'session/titleChanged'); - assert.strictEqual((actions[0] as ITitleChangedAction).title, 'New Title'); - }); - - test('first reasoning event creates a responsePart with content', () => { - const event: IAgentReasoningEvent = { - session, - type: 'reasoning', - content: 'Let me think about this...', - }; - - const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId)); - assert.strictEqual(actions.length, 1); - assert.strictEqual(actions[0].type, 'session/responsePart'); - const part = (actions[0] as IResponsePartAction).part; - assert.strictEqual(part.kind, 'reasoning'); - assert.strictEqual(part.content, 'Let me think about this...'); - assert.ok(part.id); - }); - - test('subsequent reasoning event maps to session/reasoning action', () => { - const first: IAgentReasoningEvent = { session, type: 'reasoning', content: 'Let me think...' }; - const second: IAgentReasoningEvent = { session, type: 'reasoning', content: ' more thoughts' }; - - const firstActions = mapToArray(mapper.mapProgressEventToActions(first, session.toString(), turnId)); - const partId = ((firstActions[0] as IResponsePartAction).part as ReasoningResponsePart).id; - - const secondActions = mapToArray(mapper.mapProgressEventToActions(second, session.toString(), turnId)); - assert.strictEqual(secondActions.length, 1); - const reasoning = secondActions[0] as IReasoningAction; - assert.strictEqual(reasoning.type, 'session/reasoning'); - assert.strictEqual(reasoning.content, ' more thoughts'); - assert.strictEqual(reasoning.partId, partId); - }); - - test('reasoning event after tool_start creates a fresh responsePart', () => { - // The Copilot SDK emits multiple rounds of (reasoning → message → - // tool calls) within a single chat turn. Each new reasoning batch - // after a tool call must produce a fresh Reasoning ResponsePart so - // it renders interleaved with the tool calls in the response. - // Otherwise the SessionReasoning reducer appends every later round - // onto the very first part, causing all reasoning to bunch at the - // top of the response when the session is later restored from state. - const first: IAgentReasoningEvent = { session, type: 'reasoning', content: 'Round 1 thoughts' }; - mapper.mapProgressEventToActions(first, session.toString(), turnId); - - const toolStart: IAgentToolStartEvent = { - session, type: 'tool_start', - toolCallId: 'tc-1', toolName: 'bash', displayName: 'Bash', - invocationMessage: 'Running', toolInput: 'ls', - }; - mapper.mapProgressEventToActions(toolStart, session.toString(), turnId); - - const second: IAgentReasoningEvent = { session, type: 'reasoning', content: 'Round 2 thoughts' }; - const actions = mapToArray(mapper.mapProgressEventToActions(second, session.toString(), turnId)); - assert.strictEqual(actions.length, 1); - assert.strictEqual(actions[0].type, 'session/responsePart'); - const part = (actions[0] as IResponsePartAction).part; - assert.strictEqual(part.kind, 'reasoning'); - assert.strictEqual(part.content, 'Round 2 thoughts'); - }); - - test('reasoning event after tool_complete creates a fresh responsePart', () => { - // Symmetric to the tool_start case: after a tool call finishes, - // the next reasoning batch belongs to a new round and must not - // be appended onto the previous round's reasoning part. - const first: IAgentReasoningEvent = { session, type: 'reasoning', content: 'Round 1 thoughts' }; - mapper.mapProgressEventToActions(first, session.toString(), turnId); - - const toolStart: IAgentToolStartEvent = { - session, type: 'tool_start', - toolCallId: 'tc-1', toolName: 'bash', displayName: 'Bash', - invocationMessage: 'Running', toolInput: 'ls', - }; - mapper.mapProgressEventToActions(toolStart, session.toString(), turnId); - - const toolComplete: IAgentToolCompleteEvent = { - session, type: 'tool_complete', - toolCallId: 'tc-1', - result: { success: true, content: [{ type: ToolResultContentType.Text, text: 'ok' }], pastTenseMessage: 'Ran' }, - }; - mapper.mapProgressEventToActions(toolComplete, session.toString(), turnId); - - const second: IAgentReasoningEvent = { session, type: 'reasoning', content: 'Round 2 thoughts' }; - const actions = mapToArray(mapper.mapProgressEventToActions(second, session.toString(), turnId)); - assert.strictEqual(actions.length, 1); - assert.strictEqual(actions[0].type, 'session/responsePart'); - const part = (actions[0] as IResponsePartAction).part; - assert.strictEqual(part.kind, 'reasoning'); - assert.strictEqual(part.content, 'Round 2 thoughts'); - }); - - test('message event with no prior deltas creates responsePart', () => { - const event: IAgentMessageEvent = { - session, - type: 'message', - role: 'assistant', - messageId: 'msg-1', - content: 'Some full message', - }; - - const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId)); - assert.strictEqual(actions.length, 1); - assert.strictEqual(actions[0].type, 'session/responsePart'); - const part = (actions[0] as IResponsePartAction).part; - assert.strictEqual(part.kind, 'markdown'); - assert.strictEqual(part.content, 'Some full message'); - }); - - test('message event after deltas returns undefined', () => { - // First send a delta so the mapper tracks a current markdown part - const delta: IAgentDeltaEvent = { session, type: 'delta', messageId: 'msg-1', content: 'hello' }; - mapper.mapProgressEventToActions(delta, session.toString(), turnId); - - const event: IAgentMessageEvent = { - session, - type: 'message', - role: 'assistant', - messageId: 'msg-1', - content: 'hello', - }; - - const result = mapper.mapProgressEventToActions(event, session.toString(), turnId); - assert.strictEqual(result, undefined); - }); - - test('message event after tool_start creates responsePart for post-tool text', () => { - // Delta before tool call - const delta: IAgentDeltaEvent = { session, type: 'delta', messageId: 'msg-1', content: 'before' }; - mapper.mapProgressEventToActions(delta, session.toString(), turnId); - - // Tool call clears the current markdown part - const toolStart: IAgentToolStartEvent = { - session, type: 'tool_start', - toolCallId: 'tc-1', toolName: 'bash', displayName: 'Bash', - invocationMessage: 'Running', toolInput: 'ls', - }; - mapper.mapProgressEventToActions(toolStart, session.toString(), turnId); - - // Message event with text that came after the tool call - const msg: IAgentMessageEvent = { - session, type: 'message', role: 'assistant', - messageId: 'msg-2', content: 'after tool', - }; - const actions = mapToArray(mapper.mapProgressEventToActions(msg, session.toString(), turnId)); - assert.strictEqual(actions.length, 1); - assert.strictEqual(actions[0].type, 'session/responsePart'); - const part = (actions[0] as IResponsePartAction).part; - assert.strictEqual(part.kind, 'markdown'); - assert.strictEqual(part.content, 'after tool'); - }); - - test('message event with user role returns undefined', () => { - const event: IAgentMessageEvent = { - session, type: 'message', role: 'user', - messageId: 'msg-1', content: 'user text', - }; - const result = mapper.mapProgressEventToActions(event, session.toString(), turnId); - assert.strictEqual(result, undefined); - }); - - test('message event with empty content returns undefined', () => { - const event: IAgentMessageEvent = { - session, type: 'message', role: 'assistant', - messageId: 'msg-1', content: '', - }; - const result = mapper.mapProgressEventToActions(event, session.toString(), turnId); - assert.strictEqual(result, undefined); - }); - - test('user_input_request event maps to session/inputRequested action', () => { - const request: SessionInputRequest = { - id: 'req-1', - message: 'What is your name?', - questions: [{ - kind: SessionInputQuestionKind.Text, - id: 'q-1', - message: 'What is your name?', - required: true, - }], - }; - const event: IAgentUserInputRequestEvent = { - session, - type: 'user_input_request', - request, - }; - - const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId)); - assert.strictEqual(actions.length, 1); - const action = actions[0] as SessionInputRequestedAction; - assert.strictEqual(action.type, 'session/inputRequested'); - assert.strictEqual(action.session, session.toString()); - assert.strictEqual(action.request, request); - }); - - test('tool_start with toolClientId returns only startAction (no auto-ready)', () => { - const event: IAgentToolStartEvent = { - session, - type: 'tool_start', - toolCallId: 'tc-client-1', - toolName: 'runTests', - displayName: 'Run Tests', - invocationMessage: 'Running tests...', - toolInput: '{"files":["test.ts"]}', - toolClientId: 'test-client', - }; - - const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId)); - assert.strictEqual(actions.length, 1); - - const startAction = actions[0] as IToolCallStartAction; - assert.strictEqual(startAction.type, 'session/toolCallStart'); - assert.strictEqual(startAction.toolCallId, 'tc-client-1'); - assert.strictEqual(startAction.toolClientId, 'test-client'); - }); - - test('tool_ready without confirmationTitle auto-confirms with NotNeeded', () => { - const event: IAgentToolReadyEvent = { - session, - type: 'tool_ready', - toolCallId: 'tc-client-ready', - invocationMessage: 'Running tests...', - toolInput: '{"files":["test.ts"]}', - }; - - const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId)); - assert.strictEqual(actions.length, 1); - - const readyAction = actions[0] as IToolCallReadyAction; - assert.strictEqual(readyAction.type, 'session/toolCallReady'); - assert.strictEqual(readyAction.toolCallId, 'tc-client-ready'); - assert.strictEqual(readyAction.confirmed, ToolCallConfirmationReason.NotNeeded); - }); - - test('tool_ready with confirmationTitle omits confirmed (permission flow)', () => { - const event: IAgentToolReadyEvent = { - session, - type: 'tool_ready', - toolCallId: 'tc-perm-ready', - invocationMessage: 'Running tests...', - toolInput: '{"files":["test.ts"]}', - confirmationTitle: 'Allow runTests?', - }; - - const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId)); - assert.strictEqual(actions.length, 1); - - const readyAction = actions[0] as IToolCallReadyAction; - assert.strictEqual(readyAction.type, 'session/toolCallReady'); - assert.strictEqual(readyAction.toolCallId, 'tc-perm-ready'); - assert.strictEqual(readyAction.confirmationTitle, 'Allow runTests?'); - assert.strictEqual(readyAction.confirmed, undefined); - }); - - test('tool_start with subagent metadata forwards it as `_meta`', () => { - // Per-SDK adapters (e.g. the Copilot adapter) extract subagent - // metadata from their tool argument shape and set it on the event. - // The generic mapper just forwards the fields into `_meta`. - const event: IAgentToolStartEvent = { - session, - type: 'tool_start', - toolCallId: 'tc-sub', - toolName: 'task', - displayName: 'Task', - invocationMessage: 'Delegating...', - toolKind: 'subagent', - subagentDescription: 'Review the code', - subagentAgentName: 'code-reviewer', - }; - - const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId)); - const startAction = actions[0] as IToolCallStartAction; - assert.strictEqual(startAction._meta?.toolKind, 'subagent'); - assert.strictEqual(startAction._meta?.subagentDescription, 'Review the code'); - assert.strictEqual(startAction._meta?.subagentAgentName, 'code-reviewer'); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index e71dff3ccc45c..df08b1c4f1b8a 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -25,14 +25,16 @@ import { SessionActiveClient, ResponsePartKind, SessionLifecycle, ToolCallConfir import { IProductService } from '../../../product/common/productService.js'; import { AgentService } from '../../node/agentService.js'; import { MockAgent, ScriptedMockAgent } from './mockAgent.js'; -import { mapSessionEvents, type ISessionEvent } from '../../node/copilot/mapSessionEvents.js'; +import { mapSessionEventsToHistoryRecords } from './historyRecordFixtures.js'; +import { type ISessionEvent } from '../../node/copilot/mapSessionEvents.js'; import { createNoopGitService, createSessionDataService } from '../common/sessionTestHelpers.js'; /** * Loads a JSONL fixture of raw Copilot SDK events, runs them through - * {@link mapSessionEvents}, and returns the result suitable for setting - * on {@link MockAgent.sessionMessages}. This tests the full pipeline: - * SDK events → mapSessionEvents → _buildTurnsFromMessages → Turn[]. + * {@link mapSessionEventsToHistoryRecords}, and returns the result + * suitable for setting on {@link MockAgent.sessionMessages}. Tests the + * full pipeline: SDK events → IHistoryRecord → buildTurnsFromHistory → + * Turn[]. * * Fixture files live in `test-cases/` and are sanitized copies of real * `events.jsonl` files from `~/.copilot/session-state/`. @@ -48,7 +50,7 @@ async function loadFixtureMessages(fixtureName: string, session: URI) { const sep = srcFile.includes('\\') ? '\\' : '/'; const raw = readFileSync(`${fixtureDir}${sep}test-cases${sep}${fixtureName}`, 'utf-8'); const events: ISessionEvent[] = raw.trim().split('\n').map(line => JSON.parse(line)); - return mapSessionEvents(session, undefined, events); + return mapSessionEventsToHistoryRecords(session, undefined, events); } suite('AgentService (node dispatcher)', () => { @@ -115,7 +117,10 @@ suite('AgentService (node dispatcher)', () => { const envelopes: ActionEnvelope[] = []; disposables.add(service.onDidAction(e => envelopes.push(e))); - copilotAgent.fireProgress({ session, type: 'delta', messageId: 'msg-1', content: 'hello' }); + copilotAgent.fireProgress({ + kind: 'action', session, + action: { type: ActionType.SessionResponsePart, session: session.toString(), turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-1', content: 'hello' } }, + }); assert.ok(envelopes.some(e => e.action.type === ActionType.SessionResponsePart)); }); }); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 217fa25a24085..2546d226fa105 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -22,7 +22,7 @@ import { ISessionDataService } from '../../common/sessionDataService.js'; import type { RootConfigChangedAction } from '../../common/state/protocol/actions.js'; import { CustomizationStatus } from '../../common/state/protocol/state.js'; import { ActionType, ActionEnvelope, SessionAction } from '../../common/state/sessionActions.js'; -import { AttachmentType, buildSubagentSessionUri, PendingMessageKind, ResponsePartKind, SessionStatus, ToolCallStatus, ToolResultContentType } from '../../common/state/sessionState.js'; +import { AttachmentType, buildSubagentSessionUri, PendingMessageKind, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType } from '../../common/state/sessionState.js'; import { IProductService } from '../../../product/common/productService.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { IAgentHostGitService } from '../../node/agentHostGitService.js'; @@ -351,7 +351,10 @@ suite('AgentSideEffects', () => { disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); disposables.add(sideEffects.registerProgressListener(agent)); - agent.fireProgress({ session: sessionUri, type: 'delta', messageId: 'msg-1', content: 'hi' }); + agent.fireProgress({ + kind: 'action', session: sessionUri, + action: { type: ActionType.SessionResponsePart, session: sessionUri.toString(), turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-1', content: 'hi' } }, + }); // First delta creates a response part (not a delta action) assert.ok(envelopes.some(e => e.action.type === ActionType.SessionResponsePart)); @@ -365,11 +368,17 @@ suite('AgentSideEffects', () => { disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); const listener = sideEffects.registerProgressListener(agent); - agent.fireProgress({ session: sessionUri, type: 'delta', messageId: 'msg-1', content: 'before' }); + agent.fireProgress({ + kind: 'action', session: sessionUri, + action: { type: ActionType.SessionResponsePart, session: sessionUri.toString(), turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-1', content: 'before' } }, + }); assert.strictEqual(envelopes.filter(e => e.action.type === ActionType.SessionResponsePart).length, 1); listener.dispose(); - agent.fireProgress({ session: sessionUri, type: 'delta', messageId: 'msg-2', content: 'after' }); + agent.fireProgress({ + kind: 'action', session: sessionUri, + action: { type: ActionType.SessionResponsePart, session: sessionUri.toString(), turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-2', content: 'after' } }, + }); assert.strictEqual(envelopes.filter(e => e.action.type === ActionType.SessionResponsePart).length, 1); }); }); @@ -589,7 +598,10 @@ suite('AgentSideEffects', () => { disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); // Fire idle → turn completes → queued message should be consumed - agent.fireProgress({ session: sessionUri, type: 'idle' }); + agent.fireProgress({ + kind: 'action', session: sessionUri, + action: { type: ActionType.SessionTurnComplete, session: sessionUri.toString(), turnId: 'turn-1' }, + }); const turnComplete = envelopes.find(e => e.action.type === ActionType.SessionTurnComplete); assert.ok(turnComplete, 'should dispatch session/turnComplete'); @@ -660,8 +672,8 @@ suite('AgentSideEffects', () => { // Simulate the agent consuming the steering message agent.fireProgress({ + kind: 'steering_consumed', session: sessionUri, - type: 'steering_consumed', id: 'steer-rm', }); @@ -900,21 +912,32 @@ suite('AgentSideEffects', () => { // Fire tool_start to register the tool call agent.fireProgress({ - session: sessionUri, - type: 'tool_start', - toolCallId: 'tc-conf-1', - toolName: 'read', - displayName: 'Read File', - invocationMessage: 'Reading file', + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-conf-1', toolName: 'read', displayName: 'Read File', toolClientId: undefined, + _meta: { toolKind: undefined, language: undefined }, + }, + }); + agent.fireProgress({ + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-conf-1', invocationMessage: 'Reading file', toolInput: undefined, + confirmed: ToolCallConfirmationReason.NotNeeded, + }, }); // Fire tool_ready asking for permission (non-write, so not auto-approved) agent.fireProgress({ - session: sessionUri, - type: 'tool_ready', - toolCallId: 'tc-conf-1', - invocationMessage: 'Read file.txt', - confirmationTitle: 'Read file.txt', + kind: 'pending_confirmation', session: sessionUri, + state: { + status: ToolCallStatus.PendingConfirmation, + toolCallId: 'tc-conf-1', toolName: '', displayName: '', + invocationMessage: 'Read file.txt', toolInput: undefined, + confirmationTitle: 'Read file.txt', edits: undefined, + }, + permissionKind: undefined, permissionPath: undefined, }); // Now confirm the tool call @@ -938,12 +961,20 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - session: sessionUri, - type: 'tool_start', - toolCallId: 'tc-deny-1', - toolName: 'shell', - displayName: 'Shell', - invocationMessage: 'Running command', + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-deny-1', toolName: 'shell', displayName: 'Shell', toolClientId: undefined, + _meta: { toolKind: undefined, language: undefined }, + }, + }); + agent.fireProgress({ + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-deny-1', invocationMessage: 'Running command', toolInput: undefined, + confirmed: ToolCallConfirmationReason.NotNeeded, + }, }); sideEffects.handleAction({ @@ -972,13 +1003,12 @@ suite('AgentSideEffects', () => { // tool_start puts the tool call into Streaming state agent.fireProgress({ - session: sessionUri, - type: 'tool_start', - toolCallId: 'tc-ready-1', - toolName: 'runTask', - displayName: 'Run Task', - invocationMessage: 'Running task...', - toolClientId: 'test-client', + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-ready-1', toolName: 'runTask', displayName: 'Run Task', toolClientId: 'test-client', + _meta: { toolKind: undefined, language: undefined }, + }, }); const stateAfterStart = stateManager.getSessionState(sessionUri.toString()); @@ -989,11 +1019,14 @@ suite('AgentSideEffects', () => { // tool_ready without confirmationTitle should dispatch the ready // action and advance the tool call to Running agent.fireProgress({ - session: sessionUri, - type: 'tool_ready', - toolCallId: 'tc-ready-1', - invocationMessage: 'Run Task', - toolInput: '{"task":"build"}', + kind: 'pending_confirmation', session: sessionUri, + state: { + status: ToolCallStatus.PendingConfirmation, + toolCallId: 'tc-ready-1', toolName: '', displayName: '', + invocationMessage: 'Run Task', toolInput: '{"task":"build"}', + confirmationTitle: undefined, edits: undefined, + }, + permissionKind: undefined, permissionPath: undefined, }); const stateAfterReady = stateManager.getSessionState(sessionUri.toString()); @@ -1009,24 +1042,25 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - session: sessionUri, - type: 'tool_start', - toolCallId: 'tc-perm-1', - toolName: 'write', - displayName: 'Write File', - invocationMessage: 'Writing file...', - toolClientId: 'test-client', + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-perm-1', toolName: 'write', displayName: 'Write File', toolClientId: 'test-client', + _meta: { toolKind: undefined, language: undefined }, + }, }); // tool_ready with confirmationTitle should dispatch the ready // action and advance the tool call to PendingConfirmation agent.fireProgress({ - session: sessionUri, - type: 'tool_ready', - toolCallId: 'tc-perm-1', - invocationMessage: 'Write .env', - confirmationTitle: 'Write .env', - toolInput: '{"path":".env"}', + kind: 'pending_confirmation', session: sessionUri, + state: { + status: ToolCallStatus.PendingConfirmation, + toolCallId: 'tc-perm-1', toolName: '', displayName: '', + invocationMessage: 'Write .env', toolInput: '{"path":".env"}', + confirmationTitle: 'Write .env', edits: undefined, + }, + permissionKind: undefined, permissionPath: undefined, }); const state = stateManager.getSessionState(sessionUri.toString()); @@ -1070,21 +1104,31 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - session: sessionUri, - type: 'tool_start', - toolCallId: 'tc-bypass-1', - toolName: 'write', - displayName: 'Write', - invocationMessage: 'Write .env', + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-bypass-1', toolName: 'write', displayName: 'Write', toolClientId: undefined, + _meta: { toolKind: undefined, language: undefined }, + }, + }); + agent.fireProgress({ + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-bypass-1', invocationMessage: 'Write .env', toolInput: undefined, + confirmed: ToolCallConfirmationReason.NotNeeded, + }, }); agent.fireProgress({ - session: sessionUri, - type: 'tool_ready', - toolCallId: 'tc-bypass-1', - invocationMessage: 'Write .env', - permissionKind: 'write', - permissionPath: '/workspace/.env', + kind: 'pending_confirmation', session: sessionUri, + state: { + status: ToolCallStatus.PendingConfirmation, + toolCallId: 'tc-bypass-1', toolName: '', displayName: '', + invocationMessage: 'Write .env', toolInput: undefined, + confirmationTitle: undefined, edits: undefined, + }, + permissionKind: 'write', permissionPath: '/workspace/.env', }); // .env would normally be blocked, but session-level auto-approve overrides @@ -1099,21 +1143,31 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - session: sessionUri, - type: 'tool_start', - toolCallId: 'tc-ap-shell-1', - toolName: 'shell', - displayName: 'Shell', - invocationMessage: 'Run rm -rf /', + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-ap-shell-1', toolName: 'shell', displayName: 'Shell', toolClientId: undefined, + _meta: { toolKind: undefined, language: undefined }, + }, + }); + agent.fireProgress({ + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-ap-shell-1', invocationMessage: 'Run rm -rf /', toolInput: undefined, + confirmed: ToolCallConfirmationReason.NotNeeded, + }, }); agent.fireProgress({ - session: sessionUri, - type: 'tool_ready', - toolCallId: 'tc-ap-shell-1', - invocationMessage: 'Run rm -rf /', - permissionKind: 'shell', - toolInput: 'rm -rf /', + kind: 'pending_confirmation', session: sessionUri, + state: { + status: ToolCallStatus.PendingConfirmation, + toolCallId: 'tc-ap-shell-1', toolName: '', displayName: '', + invocationMessage: 'Run rm -rf /', toolInput: 'rm -rf /', + confirmationTitle: undefined, edits: undefined, + }, + permissionKind: 'shell', permissionPath: undefined, }); // Dangerous command would normally be blocked, but session-level auto-approve overrides @@ -1128,21 +1182,31 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - session: sessionUri, - type: 'tool_start', - toolCallId: 'tc-default-1', - toolName: 'write', - displayName: 'Write', - invocationMessage: 'Write .env', + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-default-1', toolName: 'write', displayName: 'Write', toolClientId: undefined, + _meta: { toolKind: undefined, language: undefined }, + }, + }); + agent.fireProgress({ + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-default-1', invocationMessage: 'Write .env', toolInput: undefined, + confirmed: ToolCallConfirmationReason.NotNeeded, + }, }); agent.fireProgress({ - session: sessionUri, - type: 'tool_ready', - toolCallId: 'tc-default-1', - invocationMessage: 'Write .env', - permissionKind: 'write', - permissionPath: '/workspace/.env', + kind: 'pending_confirmation', session: sessionUri, + state: { + status: ToolCallStatus.PendingConfirmation, + toolCallId: 'tc-default-1', toolName: '', displayName: '', + invocationMessage: 'Write .env', toolInput: undefined, + confirmationTitle: undefined, edits: undefined, + }, + permissionKind: 'write', permissionPath: '/workspace/.env', }); // .env should still be blocked with default config @@ -1162,21 +1226,31 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - session: sessionUri, - type: 'tool_start', - toolCallId: 'tc-mid-1', - toolName: 'write', - displayName: 'Write', - invocationMessage: 'Write .env', + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-mid-1', toolName: 'write', displayName: 'Write', toolClientId: undefined, + _meta: { toolKind: undefined, language: undefined }, + }, + }); + agent.fireProgress({ + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-mid-1', invocationMessage: 'Write .env', toolInput: undefined, + confirmed: ToolCallConfirmationReason.NotNeeded, + }, }); agent.fireProgress({ - session: sessionUri, - type: 'tool_ready', - toolCallId: 'tc-mid-1', - invocationMessage: 'Write .env', - permissionKind: 'write', - permissionPath: '/workspace/.env', + kind: 'pending_confirmation', session: sessionUri, + state: { + status: ToolCallStatus.PendingConfirmation, + toolCallId: 'tc-mid-1', toolName: '', displayName: '', + invocationMessage: 'Write .env', toolInput: undefined, + confirmationTitle: undefined, edits: undefined, + }, + permissionKind: 'write', permissionPath: '/workspace/.env', }); // Should now be auto-approved after config change @@ -1196,21 +1270,31 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - session: sessionUri, - type: 'tool_start', - toolCallId: 'tc-auto-1', - toolName: 'write', - displayName: 'Write', - invocationMessage: 'Write file', + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-auto-1', toolName: 'write', displayName: 'Write', toolClientId: undefined, + _meta: { toolKind: undefined, language: undefined }, + }, + }); + agent.fireProgress({ + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-auto-1', invocationMessage: 'Write file', toolInput: undefined, + confirmed: ToolCallConfirmationReason.NotNeeded, + }, }); agent.fireProgress({ - session: sessionUri, - type: 'tool_ready', - toolCallId: 'tc-auto-1', - invocationMessage: 'Write src/app.ts', - permissionKind: 'write', - permissionPath: '/workspace/src/app.ts', + kind: 'pending_confirmation', session: sessionUri, + state: { + status: ToolCallStatus.PendingConfirmation, + toolCallId: 'tc-auto-1', toolName: '', displayName: '', + invocationMessage: 'Write src/app.ts', toolInput: undefined, + confirmationTitle: undefined, edits: undefined, + }, + permissionKind: 'write', permissionPath: '/workspace/src/app.ts', }); // Auto-approved writes call respondToPermissionRequest directly @@ -1228,22 +1312,31 @@ suite('AgentSideEffects', () => { disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); agent.fireProgress({ - session: sessionUri, - type: 'tool_start', - toolCallId: 'tc-env-1', - toolName: 'write', - displayName: 'Write', - invocationMessage: 'Write .env', + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-env-1', toolName: 'write', displayName: 'Write', toolClientId: undefined, + _meta: { toolKind: undefined, language: undefined }, + }, + }); + agent.fireProgress({ + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-env-1', invocationMessage: 'Write .env', toolInput: undefined, + confirmed: ToolCallConfirmationReason.NotNeeded, + }, }); agent.fireProgress({ - session: sessionUri, - type: 'tool_ready', - toolCallId: 'tc-env-1', - invocationMessage: 'Write .env', - permissionKind: 'write', - permissionPath: '/workspace/.env', - confirmationTitle: 'Write .env', + kind: 'pending_confirmation', session: sessionUri, + state: { + status: ToolCallStatus.PendingConfirmation, + toolCallId: 'tc-env-1', toolName: '', displayName: '', + invocationMessage: 'Write .env', toolInput: undefined, + confirmationTitle: 'Write .env', edits: undefined, + }, + permissionKind: 'write', permissionPath: '/workspace/.env', }); // Should NOT auto-approve — .env is excluded @@ -1260,22 +1353,31 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - session: sessionUri, - type: 'tool_start', - toolCallId: 'tc-pkg-1', - toolName: 'write', - displayName: 'Write', - invocationMessage: 'Write package.json', + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-pkg-1', toolName: 'write', displayName: 'Write', toolClientId: undefined, + _meta: { toolKind: undefined, language: undefined }, + }, + }); + agent.fireProgress({ + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-pkg-1', invocationMessage: 'Write package.json', toolInput: undefined, + confirmed: ToolCallConfirmationReason.NotNeeded, + }, }); agent.fireProgress({ - session: sessionUri, - type: 'tool_ready', - toolCallId: 'tc-pkg-1', - invocationMessage: 'Write package.json', - permissionKind: 'write', - permissionPath: '/workspace/package.json', - confirmationTitle: 'Write package.json', + kind: 'pending_confirmation', session: sessionUri, + state: { + status: ToolCallStatus.PendingConfirmation, + toolCallId: 'tc-pkg-1', toolName: '', displayName: '', + invocationMessage: 'Write package.json', toolInput: undefined, + confirmationTitle: 'Write package.json', edits: undefined, + }, + permissionKind: 'write', permissionPath: '/workspace/package.json', }); assert.strictEqual(agent.respondToPermissionCalls.length, 0); @@ -1287,22 +1389,31 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - session: sessionUri, - type: 'tool_start', - toolCallId: 'tc-lock-1', - toolName: 'write', - displayName: 'Write', - invocationMessage: 'Write yarn.lock', + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-lock-1', toolName: 'write', displayName: 'Write', toolClientId: undefined, + _meta: { toolKind: undefined, language: undefined }, + }, + }); + agent.fireProgress({ + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-lock-1', invocationMessage: 'Write yarn.lock', toolInput: undefined, + confirmed: ToolCallConfirmationReason.NotNeeded, + }, }); agent.fireProgress({ - session: sessionUri, - type: 'tool_ready', - toolCallId: 'tc-lock-1', - invocationMessage: 'Write yarn.lock', - permissionKind: 'write', - permissionPath: '/workspace/yarn.lock', - confirmationTitle: 'Write yarn.lock', + kind: 'pending_confirmation', session: sessionUri, + state: { + status: ToolCallStatus.PendingConfirmation, + toolCallId: 'tc-lock-1', toolName: '', displayName: '', + invocationMessage: 'Write yarn.lock', toolInput: undefined, + confirmationTitle: 'Write yarn.lock', edits: undefined, + }, + permissionKind: 'write', permissionPath: '/workspace/yarn.lock', }); assert.strictEqual(agent.respondToPermissionCalls.length, 0); @@ -1314,22 +1425,31 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - session: sessionUri, - type: 'tool_start', - toolCallId: 'tc-git-1', - toolName: 'write', - displayName: 'Write', - invocationMessage: 'Write .git/config', + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-git-1', toolName: 'write', displayName: 'Write', toolClientId: undefined, + _meta: { toolKind: undefined, language: undefined }, + }, + }); + agent.fireProgress({ + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-git-1', invocationMessage: 'Write .git/config', toolInput: undefined, + confirmed: ToolCallConfirmationReason.NotNeeded, + }, }); agent.fireProgress({ - session: sessionUri, - type: 'tool_ready', - toolCallId: 'tc-git-1', - invocationMessage: 'Write .git/config', - permissionKind: 'write', - permissionPath: '/workspace/.git/config', - confirmationTitle: 'Write .git/config', + kind: 'pending_confirmation', session: sessionUri, + state: { + status: ToolCallStatus.PendingConfirmation, + toolCallId: 'tc-git-1', toolName: '', displayName: '', + invocationMessage: 'Write .git/config', toolInput: undefined, + confirmationTitle: 'Write .git/config', edits: undefined, + }, + permissionKind: 'write', permissionPath: '/workspace/.git/config', }); assert.strictEqual(agent.respondToPermissionCalls.length, 0); @@ -1346,21 +1466,31 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - session: sessionUri, - type: 'tool_start', - toolCallId: 'tc-read-1', - toolName: 'read', - displayName: 'Read', - invocationMessage: 'Read file', + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-read-1', toolName: 'read', displayName: 'Read', toolClientId: undefined, + _meta: { toolKind: undefined, language: undefined }, + }, + }); + agent.fireProgress({ + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-read-1', invocationMessage: 'Read file', toolInput: undefined, + confirmed: ToolCallConfirmationReason.NotNeeded, + }, }); agent.fireProgress({ - session: sessionUri, - type: 'tool_ready', - toolCallId: 'tc-read-1', - invocationMessage: 'Read src/app.ts', - permissionKind: 'read', - permissionPath: '/workspace/src/app.ts', + kind: 'pending_confirmation', session: sessionUri, + state: { + status: ToolCallStatus.PendingConfirmation, + toolCallId: 'tc-read-1', toolName: '', displayName: '', + invocationMessage: 'Read src/app.ts', toolInput: undefined, + confirmationTitle: undefined, edits: undefined, + }, + permissionKind: 'read', permissionPath: '/workspace/src/app.ts', }); assert.deepStrictEqual(agent.respondToPermissionCalls, [ @@ -1377,21 +1507,31 @@ suite('AgentSideEffects', () => { disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); agent.fireProgress({ - session: sessionUri, - type: 'tool_start', - toolCallId: 'tc-read-2', - toolName: 'read', - displayName: 'Read', - invocationMessage: 'Read file', + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-read-2', toolName: 'read', displayName: 'Read', toolClientId: undefined, + _meta: { toolKind: undefined, language: undefined }, + }, + }); + agent.fireProgress({ + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-read-2', invocationMessage: 'Read file', toolInput: undefined, + confirmed: ToolCallConfirmationReason.NotNeeded, + }, }); agent.fireProgress({ - session: sessionUri, - type: 'tool_ready', - toolCallId: 'tc-read-2', - invocationMessage: 'Read /etc/passwd', - permissionKind: 'read', - permissionPath: '/etc/passwd', + kind: 'pending_confirmation', session: sessionUri, + state: { + status: ToolCallStatus.PendingConfirmation, + toolCallId: 'tc-read-2', toolName: '', displayName: '', + invocationMessage: 'Read /etc/passwd', toolInput: undefined, + confirmationTitle: undefined, edits: undefined, + }, + permissionKind: 'read', permissionPath: '/etc/passwd', }); assert.strictEqual(agent.respondToPermissionCalls.length, 0); @@ -1551,18 +1691,25 @@ suite('AgentSideEffects', () => { // Start a parent tool call agent.fireProgress({ - session: sessionUri, - type: 'tool_start', - toolCallId: 'tc-1', - toolName: 'runSubagent', - displayName: 'Run Subagent', - invocationMessage: 'Delegating task...', + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', toolClientId: undefined, + _meta: { toolKind: undefined, language: undefined }, + }, + }); + agent.fireProgress({ + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-1', invocationMessage: 'Delegating task...', toolInput: undefined, + confirmed: ToolCallConfirmationReason.NotNeeded, + }, }); // Fire subagent_started agent.fireProgress({ - session: sessionUri, - type: 'subagent_started', + kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'code-reviewer', agentDisplayName: 'Code Reviewer', @@ -1595,18 +1742,26 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); // Start parent tool + subagent - agent.fireProgress({ session: sessionUri, type: 'tool_start', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', invocationMessage: 'Delegating...' }); - agent.fireProgress({ session: sessionUri, type: 'subagent_started', toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); + agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', toolClientId: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); // Fire an inner tool start with parentToolCallId agent.fireProgress({ - session: sessionUri, - type: 'tool_start', - toolCallId: 'inner-tc-1', - toolName: 'readFile', - displayName: 'Read File', - invocationMessage: 'Reading file...', - parentToolCallId: 'tc-1', + kind: 'action', session: sessionUri, parentToolCallId: 'tc-1', + action: { + type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'inner-tc-1', toolName: 'readFile', displayName: 'Read File', toolClientId: undefined, + _meta: { toolKind: undefined, language: undefined }, + }, + }); + agent.fireProgress({ + kind: 'action', session: sessionUri, parentToolCallId: 'tc-1', + action: { + type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'inner-tc-1', invocationMessage: 'Reading file...', toolInput: undefined, + confirmed: ToolCallConfirmationReason.NotNeeded, + }, }); // Verify the inner tool call is on the subagent session's turn, not the parent @@ -1636,31 +1791,41 @@ suite('AgentSideEffects', () => { startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); - agent.fireProgress({ session: sessionUri, type: 'tool_start', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', invocationMessage: 'Delegating...' }); + agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', toolClientId: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); // Inner event arrives but `subagent_started` never does. agent.fireProgress({ - session: sessionUri, - type: 'tool_start', - toolCallId: 'inner-1', - toolName: 'read', - displayName: 'Read', - invocationMessage: 'Reading...', - parentToolCallId: 'tc-1', + kind: 'action', session: sessionUri, parentToolCallId: 'tc-1', + action: { + type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'inner-1', toolName: 'read', displayName: 'Read', toolClientId: undefined, + _meta: { toolKind: undefined, language: undefined }, + }, + }); + agent.fireProgress({ + kind: 'action', session: sessionUri, parentToolCallId: 'tc-1', + action: { + type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'inner-1', invocationMessage: 'Reading...', toolInput: undefined, + confirmed: ToolCallConfirmationReason.NotNeeded, + }, }); // Parent tool completes (e.g. it errored before delegating). agent.fireProgress({ - session: sessionUri, - type: 'tool_complete', - toolCallId: 'tc-1', - result: { success: false, pastTenseMessage: 'Failed' }, + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallComplete, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-1', + result: { success: false, pastTenseMessage: 'Failed' }, + }, }); // Now a late `subagent_started` for the same toolCallId arrives. // This is unusual but possible after a reconnect/replay. The // drain must NOT replay the (cleared) buffered inner tool call. - agent.fireProgress({ session: sessionUri, type: 'subagent_started', toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); + agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); const subagentUri = `${sessionUri.toString()}/subagent/tc-1`; const subState = stateManager.getSessionState(subagentUri); @@ -1677,15 +1842,18 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); // Start parent tool + subagent - agent.fireProgress({ session: sessionUri, type: 'tool_start', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', invocationMessage: 'Delegating...' }); - agent.fireProgress({ session: sessionUri, type: 'subagent_started', toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); + agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', toolClientId: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); // Complete the parent tool call agent.fireProgress({ - session: sessionUri, - type: 'tool_complete', - toolCallId: 'tc-1', - result: { success: true, pastTenseMessage: 'Done' }, + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallComplete, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-1', + result: { success: true, pastTenseMessage: 'Done' }, + }, }); // Verify the subagent session's turn was completed @@ -1702,11 +1870,13 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); // Start two parent tool calls with subagents - agent.fireProgress({ session: sessionUri, type: 'tool_start', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Sub 1', invocationMessage: 'Delegating 1...' }); - agent.fireProgress({ session: sessionUri, type: 'subagent_started', toolCallId: 'tc-1', agentName: 'sub1', agentDisplayName: 'Sub 1', agentDescription: 'First' }); + agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Sub 1', toolClientId: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating 1...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'sub1', agentDisplayName: 'Sub 1', agentDescription: 'First' }); - agent.fireProgress({ session: sessionUri, type: 'tool_start', toolCallId: 'tc-2', toolName: 'runSubagent', displayName: 'Sub 2', invocationMessage: 'Delegating 2...' }); - agent.fireProgress({ session: sessionUri, type: 'subagent_started', toolCallId: 'tc-2', agentName: 'sub2', agentDisplayName: 'Sub 2', agentDescription: 'Second' }); + agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-2', toolName: 'runSubagent', displayName: 'Sub 2', toolClientId: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-2', invocationMessage: 'Delegating 2...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-2', agentName: 'sub2', agentDisplayName: 'Sub 2', agentDescription: 'Second' }); // Cancel via parent turn cancellation sideEffects.handleAction({ @@ -1727,8 +1897,9 @@ suite('AgentSideEffects', () => { startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); - agent.fireProgress({ session: sessionUri, type: 'tool_start', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Sub 1', invocationMessage: 'Delegating...' }); - agent.fireProgress({ session: sessionUri, type: 'subagent_started', toolCallId: 'tc-1', agentName: 'sub', agentDisplayName: 'Sub', agentDescription: 'Has subagent' }); + agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Sub 1', toolClientId: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'sub', agentDisplayName: 'Sub', agentDescription: 'Has subagent' }); const subagentUri = `${sessionUri.toString()}/subagent/tc-1`; assert.ok(stateManager.getSessionState(subagentUri)); @@ -1743,11 +1914,15 @@ suite('AgentSideEffects', () => { startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); - agent.fireProgress({ session: sessionUri, type: 'tool_start', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', invocationMessage: 'Delegating...' }); - agent.fireProgress({ session: sessionUri, type: 'subagent_started', toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); + agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', toolClientId: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); // Fire a delta with parentToolCallId - agent.fireProgress({ session: sessionUri, type: 'delta', messageId: 'msg-sub', content: 'thinking...', parentToolCallId: 'tc-1' }); + agent.fireProgress({ + kind: 'action', session: sessionUri, parentToolCallId: 'tc-1', + action: { type: ActionType.SessionResponsePart, session: sessionUri.toString(), turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-sub', content: 'thinking...' } }, + }); // Verify the delta went to the subagent session const subagentUri = `${sessionUri.toString()}/subagent/tc-1`; @@ -1764,8 +1939,9 @@ suite('AgentSideEffects', () => { startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); - agent.fireProgress({ session: sessionUri, type: 'tool_start', toolCallId: 'tc-1', toolName: 'task', displayName: 'Task', invocationMessage: 'Delegating...' }); - agent.fireProgress({ session: sessionUri, type: 'subagent_started', toolCallId: 'tc-1', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores' }); + agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'task', displayName: 'Task', toolClientId: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores' }); // Verify subagent content is on the running tool const runningState = stateManager.getSessionState(sessionUri.toString()); @@ -1777,8 +1953,12 @@ suite('AgentSideEffects', () => { // Complete the tool — the SDK result has its own content agent.fireProgress({ - session: sessionUri, type: 'tool_complete', toolCallId: 'tc-1', - result: { success: true, pastTenseMessage: 'Delegated', content: [{ type: ToolResultContentType.Text, text: 'Done' }] }, + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallComplete, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-1', + result: { success: true, pastTenseMessage: 'Delegated', content: [{ type: ToolResultContentType.Text, text: 'Done' }] }, + }, }); // Verify the completed tool still has the subagent content entry @@ -1804,21 +1984,29 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); // 1. Parent tool starts (the `task` invocation). - agent.fireProgress({ session: sessionUri, type: 'tool_start', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', invocationMessage: 'Delegating...' }); + agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', toolClientId: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); // 2. Inner tool fires BEFORE subagent_started (race condition). agent.fireProgress({ - session: sessionUri, - type: 'tool_start', - toolCallId: 'inner-tc-1', - toolName: 'readFile', - displayName: 'Read File', - invocationMessage: 'Reading file...', - parentToolCallId: 'tc-parent', + kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', + action: { + type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'inner-tc-1', toolName: 'readFile', displayName: 'Read File', toolClientId: undefined, + _meta: { toolKind: undefined, language: undefined }, + }, + }); + agent.fireProgress({ + kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', + action: { + type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'inner-tc-1', invocationMessage: 'Reading file...', toolInput: undefined, + confirmed: ToolCallConfirmationReason.NotNeeded, + }, }); // 3. subagent_started arrives later. - agent.fireProgress({ session: sessionUri, type: 'subagent_started', toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); + agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); const subagentUri = buildSubagentSessionUri(sessionUri.toString(), 'tc-parent'); const subState = stateManager.getSessionState(subagentUri); @@ -1847,27 +2035,37 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); // Parent task tool spawns a subagent. - agent.fireProgress({ session: sessionUri, type: 'tool_start', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', invocationMessage: 'Delegating...' }); - agent.fireProgress({ session: sessionUri, type: 'subagent_started', toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); + agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', toolClientId: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); // Inner tool inside the subagent requests permission to read a file // inside the parent workspace. agent.fireProgress({ - session: sessionUri, - type: 'tool_start', - toolCallId: 'inner-read-1', - toolName: 'read', - displayName: 'Read', - invocationMessage: 'Read file', - parentToolCallId: 'tc-parent', + kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', + action: { + type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'inner-read-1', toolName: 'read', displayName: 'Read', toolClientId: undefined, + _meta: { toolKind: undefined, language: undefined }, + }, }); agent.fireProgress({ - session: sessionUri, - type: 'tool_ready', - toolCallId: 'inner-read-1', - invocationMessage: 'Read src/app.ts', - permissionKind: 'read', - permissionPath: '/workspace/src/app.ts', + kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', + action: { + type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'inner-read-1', invocationMessage: 'Read file', toolInput: undefined, + confirmed: ToolCallConfirmationReason.NotNeeded, + }, + }); + agent.fireProgress({ + kind: 'pending_confirmation', session: sessionUri, + state: { + status: ToolCallStatus.PendingConfirmation, + toolCallId: 'inner-read-1', toolName: '', displayName: '', + invocationMessage: 'Read src/app.ts', toolInput: undefined, + confirmationTitle: undefined, edits: undefined, + }, + permissionKind: 'read', permissionPath: '/workspace/src/app.ts', }); assert.deepStrictEqual(agent.respondToPermissionCalls, [ @@ -1900,27 +2098,37 @@ suite('AgentSideEffects', () => { }; } - agent.fireProgress({ session: sessionUri, type: 'tool_start', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', invocationMessage: 'Delegating...' }); - agent.fireProgress({ session: sessionUri, type: 'subagent_started', toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); + agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', toolClientId: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); // Inner write outside the workspace would normally NOT auto-approve, // but session-level autoApprove on the parent must apply. agent.fireProgress({ - session: sessionUri, - type: 'tool_start', - toolCallId: 'inner-write-1', - toolName: 'write', - displayName: 'Write', - invocationMessage: 'Write file', - parentToolCallId: 'tc-parent', + kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', + action: { + type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'inner-write-1', toolName: 'write', displayName: 'Write', toolClientId: undefined, + _meta: { toolKind: undefined, language: undefined }, + }, }); agent.fireProgress({ - session: sessionUri, - type: 'tool_ready', - toolCallId: 'inner-write-1', - invocationMessage: 'Write /tmp/foo', - permissionKind: 'write', - permissionPath: '/tmp/foo', + kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', + action: { + type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'inner-write-1', invocationMessage: 'Write file', toolInput: undefined, + confirmed: ToolCallConfirmationReason.NotNeeded, + }, + }); + agent.fireProgress({ + kind: 'pending_confirmation', session: sessionUri, + state: { + status: ToolCallStatus.PendingConfirmation, + toolCallId: 'inner-write-1', toolName: '', displayName: '', + invocationMessage: 'Write /tmp/foo', toolInput: undefined, + confirmationTitle: undefined, edits: undefined, + }, + permissionKind: 'write', permissionPath: '/tmp/foo', }); assert.deepStrictEqual(agent.respondToPermissionCalls, [ @@ -1939,21 +2147,31 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - session: sessionUri, - type: 'tool_start', - toolCallId: 'tc-perm-1', - toolName: 'CustomTool', - displayName: 'Custom Tool', - invocationMessage: 'Running custom tool', + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-perm-1', toolName: 'CustomTool', displayName: 'Custom Tool', toolClientId: undefined, + _meta: { toolKind: undefined, language: undefined }, + }, + }); + agent.fireProgress({ + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-perm-1', invocationMessage: 'Running custom tool', toolInput: undefined, + confirmed: ToolCallConfirmationReason.NotNeeded, + }, }); agent.fireProgress({ - session: sessionUri, - type: 'tool_ready', - toolCallId: 'tc-perm-1', - invocationMessage: 'Run custom tool', - confirmationTitle: 'Run custom tool', - permissionKind: 'custom-tool', + kind: 'pending_confirmation', session: sessionUri, + state: { + status: ToolCallStatus.PendingConfirmation, + toolCallId: 'tc-perm-1', toolName: '', displayName: '', + invocationMessage: 'Run custom tool', toolInput: undefined, + confirmationTitle: 'Run custom tool', edits: undefined, + }, + permissionKind: 'custom-tool', permissionPath: undefined, }); const state = stateManager.getSessionState(sessionUri.toString()); @@ -1979,21 +2197,31 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - session: sessionUri, - type: 'tool_start', - toolCallId: 'tc-perm-2', - toolName: 'CustomTool', - displayName: 'Custom Tool', - invocationMessage: 'Running custom tool', + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-perm-2', toolName: 'CustomTool', displayName: 'Custom Tool', toolClientId: undefined, + _meta: { toolKind: undefined, language: undefined }, + }, + }); + agent.fireProgress({ + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-perm-2', invocationMessage: 'Running custom tool', toolInput: undefined, + confirmed: ToolCallConfirmationReason.NotNeeded, + }, }); agent.fireProgress({ - session: sessionUri, - type: 'tool_ready', - toolCallId: 'tc-perm-2', - invocationMessage: 'Run custom tool', - confirmationTitle: 'Run custom tool', - permissionKind: 'custom-tool', + kind: 'pending_confirmation', session: sessionUri, + state: { + status: ToolCallStatus.PendingConfirmation, + toolCallId: 'tc-perm-2', toolName: '', displayName: '', + invocationMessage: 'Run custom tool', toolInput: undefined, + confirmationTitle: 'Run custom tool', edits: undefined, + }, + permissionKind: 'custom-tool', permissionPath: undefined, }); sideEffects.handleAction({ @@ -2026,21 +2254,31 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - session: sessionUri, - type: 'tool_start', - toolCallId: 'tc-perm-3', - toolName: 'CustomTool', - displayName: 'Custom Tool', - invocationMessage: 'Running custom tool', + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-perm-3', toolName: 'CustomTool', displayName: 'Custom Tool', toolClientId: undefined, + _meta: { toolKind: undefined, language: undefined }, + }, + }); + agent.fireProgress({ + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-perm-3', invocationMessage: 'Running custom tool', toolInput: undefined, + confirmed: ToolCallConfirmationReason.NotNeeded, + }, }); agent.fireProgress({ - session: sessionUri, - type: 'tool_ready', - toolCallId: 'tc-perm-3', - invocationMessage: 'Run custom tool', - confirmationTitle: 'Run custom tool', - permissionKind: 'custom-tool', + kind: 'pending_confirmation', session: sessionUri, + state: { + status: ToolCallStatus.PendingConfirmation, + toolCallId: 'tc-perm-3', toolName: '', displayName: '', + invocationMessage: 'Run custom tool', toolInput: undefined, + confirmationTitle: 'Run custom tool', edits: undefined, + }, + permissionKind: 'custom-tool', permissionPath: undefined, }); assert.deepStrictEqual(agent.respondToPermissionCalls, [ @@ -2061,16 +2299,23 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); agent.fireProgress({ - session: sessionUri, - type: 'tool_start', - toolCallId: 'tc-parent', - toolName: 'task', - displayName: 'Task', - invocationMessage: 'Delegating...', + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', toolClientId: undefined, + _meta: { toolKind: undefined, language: undefined }, + }, }); agent.fireProgress({ - session: sessionUri, - type: 'subagent_started', + kind: 'action', session: sessionUri, + action: { + type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined, + confirmed: ToolCallConfirmationReason.NotNeeded, + }, + }); + agent.fireProgress({ + kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', @@ -2078,22 +2323,31 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ - session: sessionUri, - type: 'tool_start', - toolCallId: 'inner-perm-1', - toolName: 'CustomTool', - displayName: 'Custom Tool', - invocationMessage: 'Running custom tool', - parentToolCallId: 'tc-parent', + kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', + action: { + type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'inner-perm-1', toolName: 'CustomTool', displayName: 'Custom Tool', toolClientId: undefined, + _meta: { toolKind: undefined, language: undefined }, + }, + }); + agent.fireProgress({ + kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', + action: { + type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', + toolCallId: 'inner-perm-1', invocationMessage: 'Running custom tool', toolInput: undefined, + confirmed: ToolCallConfirmationReason.NotNeeded, + }, }); agent.fireProgress({ - session: sessionUri, - type: 'tool_ready', - toolCallId: 'inner-perm-1', - invocationMessage: 'Run custom tool', - confirmationTitle: 'Run custom tool', - permissionKind: 'custom-tool', + kind: 'pending_confirmation', session: sessionUri, + state: { + status: ToolCallStatus.PendingConfirmation, + toolCallId: 'inner-perm-1', toolName: '', displayName: '', + invocationMessage: 'Run custom tool', toolInput: undefined, + confirmationTitle: 'Run custom tool', edits: undefined, + }, + permissionKind: 'custom-tool', permissionPath: undefined, }); assert.deepStrictEqual(agent.respondToPermissionCalls, [ @@ -2162,7 +2416,10 @@ suite('AgentSideEffects', () => { turnId: 'turn-1', userMessage: { text: 'hi' }, }); - localAgent.fireProgress({ session: URI.parse(sessionUri.toString()), type: 'idle' }); + localAgent.fireProgress({ + kind: 'action', session: URI.parse(sessionUri.toString()), + action: { type: ActionType.SessionTurnComplete, session: sessionUri.toString(), turnId: 'turn-1' }, + }); // Wait deterministically for the SessionDiffsChanged envelope rather // than sleeping a fixed amount. @@ -2220,7 +2477,10 @@ suite('AgentSideEffects', () => { turnId: 'turn-1', userMessage: { text: 'hi' }, }); - localAgent.fireProgress({ session: URI.parse(sessionUri.toString()), type: 'idle' }); + localAgent.fireProgress({ + kind: 'action', session: URI.parse(sessionUri.toString()), + action: { type: ActionType.SessionTurnComplete, session: sessionUri.toString(), turnId: 'turn-1' }, + }); await diffsEmitted; diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index db6c2e917bb9d..5a827d361be38 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -18,10 +18,12 @@ import { InstantiationService } from '../../../instantiation/common/instantiatio import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; import { ILogService, NullLogService } from '../../../log/common/log.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; -import { AgentSession, type IAgentDeltaEvent, type IAgentMessageEvent, type IAgentProgressEvent, type IAgentSessionMetadata, type IAgentToolStartEvent } from '../../common/agentService.js'; +import { AgentSession, type AgentSignal, type IAgentSessionMetadata } from '../../common/agentService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; -import { SessionCustomization, CustomizationRef } from '../../common/state/sessionState.js'; +import { ResponsePartKind, SessionCustomization, TurnState, type CustomizationRef, type MarkdownResponsePart, type Turn } from '../../common/state/sessionState.js'; +import { ActionType } from '../../common/state/sessionActions.js'; +import { signalToLegacyView, type LegacyMockEvent } from './mockAgent.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { IAgentHostGitService } from '../../node/agentHostGitService.js'; @@ -128,8 +130,8 @@ class TestCopilotClient implements ICopilotClient { } interface IFakeAgentSession { - send: (prompt: string, attachments?: unknown, turnId?: string) => Promise; - getMessages: () => Promise; + send: (prompt: string, attachments?: unknown, turnId?: string, announcement?: string) => Promise; + getMessages: () => Promise; dispose: () => void; } @@ -177,6 +179,9 @@ class TestableCopilotAgent extends CopilotAgent { if (!fake) { throw new Error(`No fake session registered for '${sessionId}'`); } + const sessionUri = AgentSession.uri('copilotcli', sessionId); + const emitter = (this as unknown as { _onDidSessionProgress: { fire(s: AgentSignal): void } })._onDidSessionProgress; + let turnId = ''; // `_sessions` is a DisposableMap, so it will dispose() the entry on // teardown. The fields below are the only ones touched by sendMessage // and getSessionMessages in the code under test. @@ -185,6 +190,19 @@ class TestableCopilotAgent extends CopilotAgent { getMessages: fake.getMessages, appliedSnapshot: undefined, dispose: fake.dispose, + resetTurnState: (newTurnId: string) => { turnId = newTurnId; }, + emitInitialMarkdown: (content: string) => { + emitter.fire({ + kind: 'action', + session: sessionUri, + action: { + type: ActionType.SessionResponsePart, + session: sessionUri.toString(), + turnId, + part: { kind: ResponsePartKind.Markdown, id: `synth-${Date.now()}`, content }, + }, + }); + }, } as unknown as CopilotAgentSession; return stub; } @@ -500,9 +518,8 @@ suite('CopilotAgent', () => { gitService, }) as TestableCopilotAgent; - const fakeMessages: (IAgentMessageEvent | IAgentToolStartEvent)[] = [ - { session, type: 'message', role: 'user', messageId: 'u1', content: 'hi' }, - { session, type: 'message', role: 'assistant', messageId: 'a1', content: 'hello back' }, + const fakeMessages: Turn[] = [ + { id: 'u1', userMessage: { text: 'hi' }, responseParts: [{ kind: ResponsePartKind.Markdown, id: 'a1', content: 'hello back' }], usage: undefined, state: TurnState.Complete }, ]; let sendCalls = 0; agent.registerFakeSession(sessionId, { @@ -528,19 +545,23 @@ suite('CopilotAgent', () => { assert.deepStrictEqual(gitService.addedWorktrees.length, 1, 'addWorktree must be called once'); assert.strictEqual(gitService.addedWorktrees[0].branchName, expectedBranchName); - // 2. Live path: sendMessage must fire a synthetic delta event - // carrying the announcement text before delegating to the SDK. - const events: IAgentProgressEvent[] = []; - disposables.add(agent.onDidSessionProgress(e => events.push(e))); + // 2. Live path: sendMessage must fire a synthetic markdown + // delta carrying the announcement before delegating to the + // SDK. The session is responsible for emitting the + // announcement after resetting partId tracking. + const events: LegacyMockEvent[] = []; + disposables.add(agent.onDidSessionProgress(s => { + const v = signalToLegacyView(s); + if (v) { events.push(v); } + })); await agent.sendMessage(session, 'hello'); assert.strictEqual(sendCalls, 1, 'underlying SDK send must still be called'); - const deltas = events.filter((e): e is IAgentDeltaEvent => e.type === 'delta'); + const deltas = events.filter((e): e is LegacyMockEvent & { type: 'delta' } => e.type === 'delta'); assert.strictEqual(deltas.length, 1, 'exactly one delta should be emitted for the worktree announcement'); const announcement = deltas[0]; assert.ok(announcement.content.includes(expectedBranchName), `announcement should contain branch name '${expectedBranchName}', got '${announcement.content}'`); - assert.ok(announcement.messageId.startsWith('copilot-announcement-'), `announcement messageId should be synthetic, got '${announcement.messageId}'`); // 3. Live path is one-shot: a second sendMessage must not re-emit. events.length = 0; @@ -548,13 +569,13 @@ suite('CopilotAgent', () => { assert.strictEqual(events.filter(e => e.type === 'delta').length, 0, 'announcement must not be re-emitted on subsequent sends'); // 4. Restore path: getSessionMessages must prepend the - // announcement to the first assistant message's content, + // announcement to the first turn's first markdown part, // using the persisted branch metadata. const restored = await agent.getSessionMessages(session); - const assistant = restored.find((m): m is IAgentMessageEvent => m.type === 'message' && m.role === 'assistant'); - assert.ok(assistant, 'restored messages should include the assistant reply'); - assert.ok(assistant.content.includes(expectedBranchName), `restored assistant content should include the branch name, got '${assistant.content}'`); - assert.ok(assistant.content.endsWith('hello back'), `restored assistant content should still end with the original reply, got '${assistant.content}'`); + const md = restored[0]?.responseParts.find((p): p is MarkdownResponsePart => p.kind === ResponsePartKind.Markdown); + assert.ok(md, 'restored turns should include a markdown response part'); + assert.ok(md.content.includes(expectedBranchName), `restored markdown content should include the branch name, got '${md.content}'`); + assert.ok(md.content.endsWith('hello back'), `restored markdown content should still end with the original reply, got '${md.content}'`); } finally { await disposeAgent(agent); } @@ -576,9 +597,8 @@ suite('CopilotAgent', () => { gitService, }) as TestableCopilotAgent; - const fakeMessages: IAgentMessageEvent[] = [ - { session, type: 'message', role: 'user', messageId: 'u1', content: 'hi' }, - { session, type: 'message', role: 'assistant', messageId: 'a1', content: 'untouched reply' }, + const fakeMessages: Turn[] = [ + { id: 'u1', userMessage: { text: 'hi' }, responseParts: [{ kind: ResponsePartKind.Markdown, id: 'a1', content: 'untouched reply' }], usage: undefined, state: TurnState.Complete }, ]; agent.registerFakeSession(sessionId, { send: async () => { }, @@ -592,14 +612,17 @@ suite('CopilotAgent', () => { await agent.resolveWorktreeForTest({ workingDirectory: repositoryRoot }, sessionId); assert.deepStrictEqual(gitService.addedWorktrees, [], 'addWorktree must not be called without worktree isolation'); - const events: IAgentProgressEvent[] = []; - disposables.add(agent.onDidSessionProgress(e => events.push(e))); + const events: LegacyMockEvent[] = []; + disposables.add(agent.onDidSessionProgress(s => { + const v = signalToLegacyView(s); + if (v) { events.push(v); } + })); await agent.sendMessage(session, 'hello'); assert.deepStrictEqual(events.filter(e => e.type === 'delta'), [], 'no announcement should be emitted live'); const restored = await agent.getSessionMessages(session); - const assistant = restored.find((m): m is IAgentMessageEvent => m.type === 'message' && m.role === 'assistant'); - assert.strictEqual(assistant?.content, 'untouched reply', 'restored assistant content must not be modified'); + const md = restored[0]?.responseParts.find((p): p is MarkdownResponsePart => p.kind === ResponsePartKind.Markdown); + assert.strictEqual(md?.content, 'untouched reply', 'restored markdown content must not be modified'); } finally { await disposeAgent(agent); } diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 948533c528e33..45642b876704a 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -16,10 +16,12 @@ import { IFileService } from '../../../files/common/files.js'; import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; import { ILogService, NullLogService } from '../../../log/common/log.js'; -import { AgentSession, IAgentProgressEvent, IAgentUserInputRequestEvent } from '../../common/agentService.js'; +import { AgentSession, AgentSignal } from '../../common/agentService.js'; +import { signalToLegacyView, type LegacyMockEvent } from './mockAgent.js'; import { IDiffComputeService } from '../../common/diffComputeService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; -import { AttachmentType, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolResultContentType } from '../../common/state/sessionState.js'; +import { ActionType } from '../../common/state/sessionActions.js'; +import { AttachmentType, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolResultContentType } from '../../common/state/sessionState.js'; import { CopilotAgentSession, IActiveClientSnapshot, SessionWrapperFactory } from '../../node/copilot/copilotAgentSession.js'; import { CopilotSessionWrapper } from '../../node/copilot/copilotSessionWrapper.js'; import { createSessionDataService, createZeroDiffComputeService } from '../common/sessionTestHelpers.js'; @@ -93,7 +95,7 @@ function invokeClientToolHandler(tool: Pick, toolCallI } type ISessionInternalsForTest = { - _onDidSessionProgress: { fire(event: IAgentProgressEvent): void }; + _onDidSessionProgress: { fire(event: AgentSignal): void }; _editTracker: { trackEditStart(path: string): Promise; completeEdit(path: string): Promise; @@ -114,29 +116,56 @@ async function createAgentSession(disposables: DisposableStore, options?: { }): Promise<{ session: CopilotAgentSession; mockSession: MockCopilotSession; - progressEvents: IAgentProgressEvent[]; - waitForProgress: (predicate: (event: IAgentProgressEvent) => boolean) => Promise; + progressEvents: LegacyMockEvent[]; + signals: AgentSignal[]; + waitForProgress: (predicate: (event: LegacyMockEvent) => boolean) => Promise; }> { - const progressEmitter = disposables.add(new Emitter()); - const progressEvents: IAgentProgressEvent[] = []; - const waiters: { predicate: (event: IAgentProgressEvent) => boolean; deferred: DeferredPromise }[] = []; - disposables.add(progressEmitter.event(e => { - progressEvents.push(e); + const progressEmitter = disposables.add(new Emitter()); + const progressEvents: LegacyMockEvent[] = []; + const signals: AgentSignal[] = []; + const waiters: { predicate: (event: LegacyMockEvent) => boolean; deferred: DeferredPromise }[] = []; + + const notify = (view: LegacyMockEvent): void => { + progressEvents.push(view); for (let i = waiters.length - 1; i >= 0; i--) { - if (waiters[i].predicate(e)) { + if (waiters[i].predicate(view)) { const { deferred } = waiters[i]; waiters.splice(i, 1); - deferred.complete(e); + deferred.complete(view); + } + } + }; + + disposables.add(progressEmitter.event(signal => { + signals.push(signal); + const view = signalToLegacyView(signal); + if (!view) { + return; + } + // Auto-ready Ready actions emitted in the same fire-batch as a tool_start + // describe the same logical event in the legacy vocabulary. Merge the + // invocation/toolInput/edits fields back into the prior tool_start view + // so existing tests that assert on `tool_start.invocationMessage` etc. + // see a single combined entry. Permission-driven ready signals (kind: + // 'tool_ready') and externally triggered ready actions are pushed + // separately. + if (view.type === 'tool_ready' && signal.kind === 'action') { + const last = progressEvents[progressEvents.length - 1]; + if (last?.type === 'tool_start' && last.toolCallId === view.toolCallId) { + last.invocationMessage = view.invocationMessage; + last.toolInput = view.toolInput; + return; } } + notify(view); })); - const waitForProgress = (predicate: (event: IAgentProgressEvent) => boolean): Promise => { + const waitForProgress = (predicate: (event: LegacyMockEvent) => boolean): Promise => { const existing = progressEvents.find(predicate); if (existing) { return Promise.resolve(existing); } - const deferred = new DeferredPromise(); + const deferred = new DeferredPromise(); waiters.push({ predicate, deferred }); return deferred.p; }; @@ -180,7 +209,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { await session.initializeSession(); - return { session, mockSession, progressEvents, waitForProgress }; + return { session, mockSession, progressEvents, signals, waitForProgress }; } // ---- Tests ------------------------------------------------------------------ @@ -693,7 +722,7 @@ suite('CopilotAgentSession', () => { } }); - test('complete message with tool requests is forwarded', async () => { + test('complete assistant message without preceding deltas surfaces a markdown response part', async () => { const { mockSession, progressEvents } = await createAgentSession(disposables); mockSession.fire('assistant.message', { messageId: 'msg-2', @@ -706,21 +735,70 @@ suite('CopilotAgentSession', () => { }], } as SessionEventPayload<'assistant.message'>['data']); + // The session emits a fresh markdown response part for the + // content. Tool calls fire their own `tool_start` events, so + // `toolRequests` on the assistant message are not forwarded + // during live streaming. assert.strictEqual(progressEvents.length, 1); - assert.strictEqual(progressEvents[0].type, 'message'); - if (progressEvents[0].type === 'message') { + assert.strictEqual(progressEvents[0].type, 'delta'); + if (progressEvents[0].type === 'delta') { assert.strictEqual(progressEvents[0].content, 'Let me help you.'); - assert.strictEqual(progressEvents[0].toolRequests?.length, 1); - assert.strictEqual(progressEvents[0].toolRequests?.[0].toolCallId, 'tc-20'); } }); + + test('reasoning delta after tool_start starts a new reasoning response part', async () => { + const { mockSession, signals } = await createAgentSession(disposables); + + // First reasoning delta — allocates a fresh reasoning response part. + mockSession.fire('assistant.reasoning_delta', { + deltaContent: 'thinking step 1', + } as SessionEventPayload<'assistant.reasoning_delta'>['data']); + + // A tool call interleaves between reasoning rounds. + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-r-1', + toolName: 'bash', + arguments: { command: 'echo hi' }, + } as SessionEventPayload<'tool.execution_start'>['data']); + mockSession.fire('tool.execution_complete', { + toolCallId: 'tc-r-1', + success: true, + result: { content: 'hi' }, + } as SessionEventPayload<'tool.execution_complete'>['data']); + + // Second round of reasoning, after the tool call. This must + // land in a NEW reasoning response part — otherwise the + // renderer / state-tree would merge it into the pre-tool-call + // block and the visual ordering would be wrong on restore. + mockSession.fire('assistant.reasoning_delta', { + deltaContent: 'thinking step 2', + } as SessionEventPayload<'assistant.reasoning_delta'>['data']); + + // Pull the protocol-level reasoning response parts. Both + // `SessionResponsePart{Reasoning}` (allocates a new part) and + // `SessionReasoning` (appends to an existing part) translate to + // the legacy `'reasoning'` view, so we have to inspect raw + // signals to tell them apart. + const reasoningResponseParts = signals.flatMap(s => { + if (s.kind !== 'action' || s.action.type !== ActionType.SessionResponsePart) { + return []; + } + return s.action.part.kind === ResponsePartKind.Reasoning ? [s.action.part] : []; + }); + assert.strictEqual(reasoningResponseParts.length, 2, + 'reasoning after a tool call should allocate a new response part, not append to the part from before the tool call'); + assert.notStrictEqual(reasoningResponseParts[0].id, reasoningResponseParts[1].id, + 'second reasoning round should have a distinct part id'); + assert.strictEqual(reasoningResponseParts[0].content, 'thinking step 1'); + assert.strictEqual(reasoningResponseParts[1].content, 'thinking step 2'); + }); }); // ---- user input handling ---- suite('user input handling', () => { - function assertUserInputEvent(event: IAgentProgressEvent): asserts event is IAgentUserInputRequestEvent { + function assertUserInputEvent(event: LegacyMockEvent): asserts event is LegacyMockEvent & { type: 'user_input_request' } { assert.strictEqual(event.type, 'user_input_request'); } diff --git a/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts b/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts index 07d9471f38bca..4cc8b678e645d 100644 --- a/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellLanguage, getToolInputString, getToolKind, isHiddenTool, synthesizeSkillToolEvents, type ITypedPermissionRequest } from '../../node/copilot/copilotToolDisplay.js'; +import { getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellLanguage, getToolInputString, getToolKind, isHiddenTool, synthesizeSkillToolCall, type ITypedPermissionRequest } from '../../node/copilot/copilotToolDisplay.js'; suite('getPermissionDisplay — cd-prefix stripping', () => { @@ -298,41 +298,33 @@ suite('skill events', () => { ensureNoDisposablesAreLeakedInTestSuite(); - const session = URI.parse('agent://copilot/test'); - test('hides the raw `skill` tool call and synthesizes a tool-start/complete pair from `skill.invoked`', () => { - const withPath = synthesizeSkillToolEvents( - session, + const withPath = synthesizeSkillToolCall( { name: 'plan', path: '/abs/repo/skills/plan/SKILL.md' }, 'evt-123', ); - const noPath = synthesizeSkillToolEvents( - session, + const noPath = synthesizeSkillToolCall( { name: 'plan' }, undefined, ); assert.deepStrictEqual({ skillIsHidden: isHiddenTool('skill'), - withPathToolCallId: withPath.start.toolCallId, - withPathSameIdOnComplete: withPath.start.toolCallId === withPath.complete.toolCallId, - withPathToolName: withPath.start.toolName, - withPathDisplayName: withPath.start.displayName, - withPathInvocation: withPath.start.invocationMessage, - withPathPastTense: withPath.complete.result.pastTenseMessage, - withPathSuccess: withPath.complete.result.success, - noPathToolCallId: noPath.start.toolCallId, - noPathInvocation: noPath.start.invocationMessage, - noPathPastTense: noPath.complete.result.pastTenseMessage, + withPathToolCallId: withPath.toolCallId, + withPathToolName: withPath.toolName, + withPathDisplayName: withPath.displayName, + withPathInvocation: withPath.invocationMessage, + withPathPastTense: withPath.pastTenseMessage, + noPathToolCallId: noPath.toolCallId, + noPathInvocation: noPath.invocationMessage, + noPathPastTense: noPath.pastTenseMessage, }, { skillIsHidden: true, withPathToolCallId: 'synth-skill-evt-123', - withPathSameIdOnComplete: true, withPathToolName: 'skill', withPathDisplayName: 'Read Skill', withPathInvocation: { markdown: 'Reading skill [plan](file:///abs/repo/skills/plan/SKILL.md)' }, withPathPastTense: { markdown: 'Read skill [plan](file:///abs/repo/skills/plan/SKILL.md)' }, - withPathSuccess: true, noPathToolCallId: 'synth-skill-2108d652', noPathInvocation: 'Reading skill plan', noPathPastTense: 'Read skill plan', diff --git a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/historyRecordFixtures.test.ts similarity index 88% rename from src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts rename to src/vs/platform/agentHost/test/node/historyRecordFixtures.test.ts index d5105061035fb..29b5ed9d395ac 100644 --- a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/historyRecordFixtures.test.ts @@ -11,9 +11,10 @@ import { AgentSession } from '../../common/agentService.js'; import { FileEditKind, ToolResultContentType } from '../../common/state/sessionState.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { parseSessionDbUri } from '../../node/copilot/fileEditTracker.js'; -import { mapSessionEvents, type ISessionEvent } from '../../node/copilot/mapSessionEvents.js'; +import { mapSessionEventsToHistoryRecords } from './historyRecordFixtures.js'; +import { type ISessionEvent } from '../../node/copilot/mapSessionEvents.js'; -suite('mapSessionEvents', () => { +suite('mapSessionEventsToHistoryRecords', () => { const disposables = new DisposableStore(); let db: SessionDatabase | undefined; @@ -33,7 +34,7 @@ suite('mapSessionEvents', () => { { type: 'assistant.message', data: { messageId: 'msg-2', content: 'world' } }, ]; - const result = await mapSessionEvents(session, undefined, events); + const result = await mapSessionEventsToHistoryRecords(session, undefined, events); assert.strictEqual(result.length, 2); assert.deepStrictEqual(result[0], { session, @@ -63,7 +64,7 @@ suite('mapSessionEvents', () => { }, ]; - const result = await mapSessionEvents(session, undefined, events); + const result = await mapSessionEventsToHistoryRecords(session, undefined, events); assert.strictEqual(result.length, 2); assert.strictEqual(result[0].type, 'tool_start'); assert.strictEqual(result[1].type, 'tool_complete'); @@ -78,7 +79,7 @@ suite('mapSessionEvents', () => { { type: 'tool.execution_complete', data: { toolCallId: 'orphan', success: true } }, ]; - const result = await mapSessionEvents(session, undefined, events); + const result = await mapSessionEventsToHistoryRecords(session, undefined, events); assert.strictEqual(result.length, 0); }); @@ -88,7 +89,7 @@ suite('mapSessionEvents', () => { { type: 'user.message', data: { messageId: 'msg-1', content: 'test' } }, ]; - const result = await mapSessionEvents(session, undefined, events); + const result = await mapSessionEventsToHistoryRecords(session, undefined, events); assert.strictEqual(result.length, 1); }); @@ -121,7 +122,7 @@ suite('mapSessionEvents', () => { }, ]; - const result = await mapSessionEvents(session, db, events); + const result = await mapSessionEventsToHistoryRecords(session, db, events); const complete = result[1]; assert.strictEqual(complete.type, 'tool_complete'); @@ -177,7 +178,7 @@ suite('mapSessionEvents', () => { }, ]; - const result = await mapSessionEvents(session, db, events); + const result = await mapSessionEventsToHistoryRecords(session, db, events); const content = (result[1] as { result: { content?: readonly Record[] } }).result.content; assert.ok(content); // Two file edits (no text since result had no content) @@ -197,7 +198,7 @@ suite('mapSessionEvents', () => { }, ]; - const result = await mapSessionEvents(session, undefined, events); + const result = await mapSessionEventsToHistoryRecords(session, undefined, events); const content = (result[1] as { result: { content?: readonly Record[] } }).result.content; assert.ok(content); // Only text content, no file edits @@ -219,7 +220,7 @@ suite('mapSessionEvents', () => { }, ]; - const result = await mapSessionEvents(session, db, events); + const result = await mapSessionEventsToHistoryRecords(session, db, events); const content = (result[1] as { result: { content?: readonly Record[] } }).result.content; assert.ok(content); assert.strictEqual(content.length, 1); @@ -244,7 +245,7 @@ suite('mapSessionEvents', () => { }, ]; - const result = await mapSessionEvents(session, undefined, events); + const result = await mapSessionEventsToHistoryRecords(session, undefined, events); assert.strictEqual(result.length, 1); assert.strictEqual(result[0].type, 'subagent_started'); const event = result[0] as { type: string; toolCallId: string; agentName: string; agentDisplayName: string }; @@ -283,7 +284,7 @@ suite('mapSessionEvents', () => { }, ]; - const result = await mapSessionEvents(session, undefined, events); + const result = await mapSessionEventsToHistoryRecords(session, undefined, events); assert.deepStrictEqual({ count: result.length, @@ -329,12 +330,12 @@ suite('mapSessionEvents', () => { }; } - function getStart(events: ReturnType extends Promise ? R : never) { + function getStart(events: ReturnType extends Promise ? R : never) { return events[0] as { toolInput: string; toolArguments?: string }; } test('strips redundant bash cd prefix matching workingDirectory', async () => { - const result = await mapSessionEvents(session, undefined, [ + const result = await mapSessionEventsToHistoryRecords(session, undefined, [ makeBashEvent('cd /workspace/proj && ls -la'), ], cwd); const start = getStart(result); @@ -343,7 +344,7 @@ suite('mapSessionEvents', () => { }); test('leaves command unchanged when cd dir does not match', async () => { - const result = await mapSessionEvents(session, undefined, [ + const result = await mapSessionEventsToHistoryRecords(session, undefined, [ makeBashEvent('cd /other && ls'), ], cwd); const start = getStart(result); @@ -351,7 +352,7 @@ suite('mapSessionEvents', () => { }); test('leaves command unchanged when no workingDirectory provided', async () => { - const result = await mapSessionEvents(session, undefined, [ + const result = await mapSessionEventsToHistoryRecords(session, undefined, [ makeBashEvent('cd /workspace/proj && ls'), ]); const start = getStart(result); @@ -359,7 +360,7 @@ suite('mapSessionEvents', () => { }); test('non-shell tools are not rewritten even with matching command field', async () => { - const result = await mapSessionEvents(session, undefined, [ + const result = await mapSessionEventsToHistoryRecords(session, undefined, [ { type: 'tool.execution_start', data: { toolCallId: 'tc-1', toolName: 'edit', arguments: { command: 'cd /workspace/proj && ls' } }, @@ -371,7 +372,7 @@ suite('mapSessionEvents', () => { }); test('handles trailing slash on workingDirectory', async () => { - const result = await mapSessionEvents(session, undefined, [ + const result = await mapSessionEventsToHistoryRecords(session, undefined, [ makeBashEvent('cd /workspace/proj && ls'), ], URI.file('/workspace/proj/')); const start = getStart(result); @@ -380,7 +381,7 @@ suite('mapSessionEvents', () => { test('handles quoted directory in cd prefix', async () => { const cwdWithSpaces = URI.file('/workspace/my proj'); - const result = await mapSessionEvents(session, undefined, [ + const result = await mapSessionEventsToHistoryRecords(session, undefined, [ makeBashEvent('cd "/workspace/my proj" && ls'), ], cwdWithSpaces); const start = getStart(result); @@ -388,7 +389,7 @@ suite('mapSessionEvents', () => { }); test('rewrites powershell commands too', async () => { - const result = await mapSessionEvents(session, undefined, [ + const result = await mapSessionEventsToHistoryRecords(session, undefined, [ { type: 'tool.execution_start', data: { toolCallId: 'tc-1', toolName: 'powershell', arguments: { command: 'cd /workspace/proj; dir' } }, diff --git a/src/vs/platform/agentHost/test/node/historyRecordFixtures.ts b/src/vs/platform/agentHost/test/node/historyRecordFixtures.ts new file mode 100644 index 0000000000000..b4bee42da2d4d --- /dev/null +++ b/src/vs/platform/agentHost/test/node/historyRecordFixtures.ts @@ -0,0 +1,552 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../base/common/uri.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; +import { IFileEditRecord, ISessionDatabase } from '../../common/sessionDataService.js'; +import { ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, type ResponsePart, type StringOrMarkdown, type ToolCallCompletedState, type ToolResultContent, type Turn } from '../../common/state/sessionState.js'; +import { getInvocationMessage, getPastTenseMessage, getShellLanguage, getSubagentMetadata, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, synthesizeSkillToolCall } from '../../node/copilot/copilotToolDisplay.js'; +import { buildSessionDbUri } from '../../node/copilot/fileEditTracker.js'; +import type { ISessionEvent, ISessionEventMessage, ISessionEventSkillInvoked, ISessionEventSubagentStarted, ISessionEventToolComplete, ISessionEventToolStart } from '../../node/copilot/mapSessionEvents.js'; + +// ============================================================================= +// History-record test fixtures +// +// Flat, declarative DSL used by mock agents and unit tests to build session +// history without manually constructing `Turn[]`. Records mirror the wire +// shape of an SDK event stream — `message`, `tool_start`, `tool_complete`, +// `subagent_started` — so transcripts read like the protocol they're +// emulating. +// +// Production code does NOT depend on this module. The real +// SDK-events-to-Turn[] pipeline in `node/copilot/mapSessionEvents.ts` runs +// in a single pass without producing the intermediate record shape. +// ============================================================================= + +interface IHistoryRecordBase { + readonly session: URI; +} + +interface IHistoryMessageRecord extends IHistoryRecordBase { + readonly type: 'message'; + readonly role: 'user' | 'assistant'; + readonly messageId: string; + readonly content: string; + readonly toolRequests?: readonly { + readonly toolCallId: string; + readonly name: string; + readonly arguments?: string; + readonly type?: 'function' | 'custom'; + }[]; + readonly reasoningOpaque?: string; + readonly reasoningText?: string; + readonly encryptedContent?: string; + readonly parentToolCallId?: string; +} + +export interface IHistoryToolStartRecord extends IHistoryRecordBase { + readonly type: 'tool_start'; + readonly toolCallId: string; + readonly toolName: string; + readonly displayName: string; + readonly invocationMessage: StringOrMarkdown; + readonly toolInput?: string; + readonly toolKind?: 'terminal' | 'subagent'; + readonly language?: string; + readonly toolArguments?: string; + readonly subagentAgentName?: string; + readonly subagentDescription?: string; + readonly mcpServerName?: string; + readonly mcpToolName?: string; + readonly parentToolCallId?: string; +} + +interface IHistoryToolCompleteRecord extends IHistoryRecordBase { + readonly type: 'tool_complete'; + readonly toolCallId: string; + readonly result: { + readonly success: boolean; + readonly pastTenseMessage: StringOrMarkdown; + readonly content?: ToolResultContent[]; + readonly error?: { readonly message: string; readonly code?: string }; + }; + readonly isUserRequested?: boolean; + readonly toolTelemetry?: string; + readonly parentToolCallId?: string; +} + +interface IHistorySubagentStartedRecord extends IHistoryRecordBase { + readonly type: 'subagent_started'; + readonly toolCallId: string; + readonly agentName: string; + readonly agentDisplayName: string; + readonly agentDescription?: string; +} + +/** Test fixture record. Hand-constructed by tests to seed mock session histories. */ +export type IHistoryRecord = + | IHistoryMessageRecord + | IHistoryToolStartRecord + | IHistoryToolCompleteRecord + | IHistorySubagentStartedRecord; + +function extractSubagentMeta(start: IHistoryToolStartRecord | undefined): { subagentDescription?: string; subagentAgentName?: string } { + if (!start) { + return {}; + } + return { + subagentDescription: start.subagentDescription, + subagentAgentName: start.subagentAgentName, + }; +} + +/** + * Builds a parent session's {@link Turn}s from a flat list of history + * records. + * + * Each `user` message starts a new turn. Inner subagent records (those + * carrying `parentToolCallId`) are skipped — see + * {@link buildSubagentTurnsFromHistory}. + */ +export function buildTurnsFromHistory(messages: readonly IHistoryRecord[]): Turn[] { + const turns: Turn[] = []; + const subagentsByToolCallId = new Map(); + let currentTurn: { + id: string; + userMessage: { text: string }; + responseParts: ResponsePart[]; + pendingTools: Map; + } | undefined; + + const finalizeTurn = (turn: NonNullable, state: TurnState): void => { + turns.push({ + id: turn.id, + userMessage: turn.userMessage, + responseParts: turn.responseParts, + usage: undefined, + state, + }); + }; + + const startTurn = (id: string, text: string): NonNullable => ({ + id, + userMessage: { text }, + responseParts: [], + pendingTools: new Map(), + }); + + for (const msg of messages) { + if (msg.type === 'message' && msg.role === 'user') { + if (currentTurn) { + finalizeTurn(currentTurn, TurnState.Cancelled); + } + currentTurn = startTurn(msg.messageId, msg.content); + } else if (msg.type === 'message' && msg.role === 'assistant') { + if (msg.parentToolCallId) { + continue; + } + if (!currentTurn) { + currentTurn = startTurn(msg.messageId, ''); + } + if (msg.reasoningText) { + currentTurn.responseParts.push({ + kind: ResponsePartKind.Reasoning, + id: generateUuid(), + content: msg.reasoningText, + }); + } + if (msg.content) { + currentTurn.responseParts.push({ + kind: ResponsePartKind.Markdown, + id: generateUuid(), + content: msg.content, + }); + } + if (!msg.toolRequests || msg.toolRequests.length === 0) { + finalizeTurn(currentTurn, TurnState.Complete); + currentTurn = undefined; + } + } else if (msg.type === 'subagent_started') { + subagentsByToolCallId.set(msg.toolCallId, msg); + } else if (msg.type === 'tool_start') { + if (msg.parentToolCallId) { + continue; + } + currentTurn?.pendingTools.set(msg.toolCallId, msg); + } else if (msg.type === 'tool_complete') { + if (msg.parentToolCallId) { + continue; + } + if (currentTurn) { + const start = currentTurn.pendingTools.get(msg.toolCallId); + currentTurn.pendingTools.delete(msg.toolCallId); + + const subagentEvent = subagentsByToolCallId.get(msg.toolCallId); + const contentWithSubagent = msg.result.content ? [...msg.result.content] : []; + if (subagentEvent) { + const parentSessionStr = msg.session.toString(); + contentWithSubagent.push({ + type: ToolResultContentType.Subagent, + resource: buildSubagentSessionUri(parentSessionStr, msg.toolCallId), + title: subagentEvent.agentDisplayName, + agentName: subagentEvent.agentName, + description: subagentEvent.agentDescription, + }); + } + + const tc: ToolCallCompletedState = { + status: ToolCallStatus.Completed, + toolCallId: msg.toolCallId, + toolName: start?.toolName ?? 'unknown', + displayName: start?.displayName ?? 'Unknown Tool', + invocationMessage: start?.invocationMessage ?? 'Unknown tool', + toolInput: start?.toolInput, + success: msg.result.success, + pastTenseMessage: msg.result.pastTenseMessage, + content: contentWithSubagent.length > 0 ? contentWithSubagent : undefined, + error: msg.result.error, + confirmed: ToolCallConfirmationReason.NotNeeded, + _meta: { + toolKind: start?.toolKind, + language: start?.language, + ...extractSubagentMeta(start), + }, + }; + currentTurn.responseParts.push({ + kind: ResponsePartKind.ToolCall, + toolCall: tc, + }); + } + } + } + + if (currentTurn) { + finalizeTurn(currentTurn, TurnState.Cancelled); + } + + return turns; +} + +/** + * Builds the {@link Turn}s for a subagent child session by filtering the + * parent's history for records carrying the matching `parentToolCallId`. + * Returns a single turn containing all inner tool calls and assistant + * messages. + */ +export function buildSubagentTurnsFromHistory( + parentMessages: readonly IHistoryRecord[], + parentToolCallId: string, + childSessionUri: string, +): Turn[] { + const innerToolCallIds = new Set(); + for (const msg of parentMessages) { + if ((msg.type === 'tool_start' || msg.type === 'tool_complete') && msg.parentToolCallId === parentToolCallId) { + innerToolCallIds.add(msg.toolCallId); + } + } + + const subagentsByToolCallId = new Map(); + for (const msg of parentMessages) { + if (msg.type === 'subagent_started' && innerToolCallIds.has(msg.toolCallId)) { + subagentsByToolCallId.set(msg.toolCallId, msg); + } + } + + const innerMessages = parentMessages.filter(msg => { + if (msg.type === 'tool_start' || msg.type === 'tool_complete') { + return msg.parentToolCallId === parentToolCallId; + } + if (msg.type === 'message') { + return msg.parentToolCallId === parentToolCallId; + } + return false; + }); + + if (innerMessages.length === 0) { + return []; + } + + const responseParts: ResponsePart[] = []; + const pendingTools = new Map(); + + for (const msg of innerMessages) { + if (msg.type === 'tool_start') { + pendingTools.set(msg.toolCallId, msg); + } else if (msg.type === 'tool_complete') { + const start = pendingTools.get(msg.toolCallId); + pendingTools.delete(msg.toolCallId); + + const subagentEvent = subagentsByToolCallId.get(msg.toolCallId); + const contentWithSubagent = msg.result.content ? [...msg.result.content] : []; + if (subagentEvent) { + contentWithSubagent.push({ + type: ToolResultContentType.Subagent, + resource: buildSubagentSessionUri(childSessionUri, msg.toolCallId), + title: subagentEvent.agentDisplayName, + agentName: subagentEvent.agentName, + description: subagentEvent.agentDescription, + }); + } + + const tc: ToolCallCompletedState = { + status: ToolCallStatus.Completed, + toolCallId: msg.toolCallId, + toolName: start?.toolName ?? 'unknown', + displayName: start?.displayName ?? 'Unknown Tool', + invocationMessage: start?.invocationMessage ?? 'Unknown tool', + toolInput: start?.toolInput, + success: msg.result.success, + pastTenseMessage: msg.result.pastTenseMessage, + content: contentWithSubagent.length > 0 ? contentWithSubagent : undefined, + error: msg.result.error, + confirmed: ToolCallConfirmationReason.NotNeeded, + _meta: { + toolKind: start?.toolKind, + language: start?.language, + ...extractSubagentMeta(start), + }, + }; + responseParts.push({ + kind: ResponsePartKind.ToolCall, + toolCall: tc, + }); + } else if (msg.type === 'message' && msg.role === 'assistant') { + if (msg.reasoningText) { + responseParts.push({ + kind: ResponsePartKind.Reasoning, + id: generateUuid(), + content: msg.reasoningText, + }); + } + if (msg.content) { + responseParts.push({ + kind: ResponsePartKind.Markdown, + id: generateUuid(), + content: msg.content, + }); + } + } + } + + if (responseParts.length === 0) { + return []; + } + + return [{ + id: generateUuid(), + userMessage: { text: '' }, + responseParts, + usage: undefined, + state: TurnState.Complete, + }]; +} + +// ============================================================================= +// SDK-events-to-history-records (test fixture loader) +// +// Translates raw Copilot SDK session events into a flat IHistoryRecord +// stream. This is the test-side equivalent of the production single-pass +// `mapSessionEvents` (which goes directly to Turn[]). It exists so JSONL +// fixtures captured from real `~/.copilot/session-state/` files can be +// loaded into the test DSL without forcing tests to also adopt Turn[]. +// ============================================================================= + +function tryStringify(value: unknown): string | undefined { + try { + return JSON.stringify(value); + } catch { + return undefined; + } +} + +function isSyntheticUserMessage(event: ISessionEvent): boolean { + if (event.type !== 'user.message') { + return false; + } + const source = (event as ISessionEventMessage).data?.source; + return !!source && source.toLowerCase() !== 'user'; +} + +/** + * Maps raw SDK session events into a flat list of {@link IHistoryRecord}s, + * restoring stored file-edit metadata from the session database when + * available. Test-fixture-only. + */ +export async function mapSessionEventsToHistoryRecords( + session: URI, + db: ISessionDatabase | undefined, + events: readonly ISessionEvent[], + workingDirectory?: URI, +): Promise { + const result: IHistoryRecord[] = []; + const toolInfoByCallId = new Map | undefined; rewrittenArgs?: string }>(); + const editToolCallIds: string[] = []; + + for (const e of events) { + if (e.type === 'tool.execution_start') { + const d = (e as ISessionEventToolStart).data; + if (isHiddenTool(d.toolName)) { + continue; + } + const toolArgs = d.arguments !== undefined ? tryStringify(d.arguments) : undefined; + let parameters: Record | undefined; + if (toolArgs) { + try { parameters = JSON.parse(toolArgs) as Record; } catch { /* ignore */ } + } + const rewrittenArgs = stripRedundantCdPrefix(d.toolName, parameters, workingDirectory) ? tryStringify(parameters) : undefined; + toolInfoByCallId.set(d.toolCallId, { toolName: d.toolName, parameters, rewrittenArgs }); + if (isEditTool(d.toolName)) { + editToolCallIds.push(d.toolCallId); + } + } + } + + let storedEdits: Map | undefined; + if (db && editToolCallIds.length > 0) { + try { + const records = await db.getFileEdits(editToolCallIds); + if (records.length > 0) { + storedEdits = new Map(); + for (const r of records) { + let list = storedEdits.get(r.toolCallId); + if (!list) { + list = []; + storedEdits.set(r.toolCallId, list); + } + list.push(r); + } + } + } catch { + // Database may not exist yet — that's fine. + } + } + + const sessionUriStr = session.toString(); + + for (const e of events) { + if (e.type === 'assistant.message' || e.type === 'user.message') { + if (isSyntheticUserMessage(e)) { + continue; + } + const d = (e as ISessionEventMessage).data; + result.push({ + session, + type: 'message', + role: e.type === 'user.message' ? 'user' : 'assistant', + messageId: d?.messageId ?? d?.interactionId ?? '', + content: d?.content ?? '', + toolRequests: d?.toolRequests?.map(tr => ({ + toolCallId: tr.toolCallId, + name: tr.name, + arguments: tr.arguments !== undefined ? tryStringify(tr.arguments) : undefined, + type: tr.type, + })), + reasoningOpaque: d?.reasoningOpaque, + reasoningText: d?.reasoningText, + encryptedContent: d?.encryptedContent, + parentToolCallId: d?.parentToolCallId, + }); + } else if (e.type === 'tool.execution_start') { + const d = (e as ISessionEventToolStart).data; + if (isHiddenTool(d.toolName)) { + continue; + } + const info = toolInfoByCallId.get(d.toolCallId); + const displayName = getToolDisplayName(d.toolName); + const toolKind = getToolKind(d.toolName); + const toolArgs = info?.rewrittenArgs ?? (d.arguments !== undefined ? tryStringify(d.arguments) : undefined); + const subagentMeta = toolKind === 'subagent' ? getSubagentMetadata(info?.parameters) : undefined; + result.push({ + session, + type: 'tool_start', + toolCallId: d.toolCallId, + toolName: d.toolName, + displayName, + invocationMessage: getInvocationMessage(d.toolName, displayName, info?.parameters), + toolInput: getToolInputString(d.toolName, info?.parameters, toolArgs), + toolKind, + language: toolKind === 'terminal' ? getShellLanguage(d.toolName) : undefined, + toolArguments: toolArgs, + subagentAgentName: subagentMeta?.agentName, + subagentDescription: subagentMeta?.description, + mcpServerName: d.mcpServerName, + mcpToolName: d.mcpToolName, + parentToolCallId: d.parentToolCallId, + }); + } else if (e.type === 'tool.execution_complete') { + const d = (e as ISessionEventToolComplete).data; + const info = toolInfoByCallId.get(d.toolCallId); + if (!info) { + continue; + } + toolInfoByCallId.delete(d.toolCallId); + const displayName = getToolDisplayName(info.toolName); + const toolOutput = d.error?.message ?? d.result?.content; + const content: ToolResultContent[] = []; + if (toolOutput !== undefined) { + content.push({ type: ToolResultContentType.Text, text: toolOutput }); + } + const edits = storedEdits?.get(d.toolCallId); + if (edits) { + for (const edit of edits) { + const beforeUri = edit.kind === 'rename' && edit.originalPath + ? URI.file(edit.originalPath).toString() + : URI.file(edit.filePath).toString(); + const afterUri = URI.file(edit.filePath).toString(); + const hasBefore = edit.kind !== 'create'; + const hasAfter = edit.kind !== 'delete'; + content.push({ + type: ToolResultContentType.FileEdit, + before: hasBefore ? { + uri: beforeUri, + content: { uri: buildSessionDbUri(sessionUriStr, edit.toolCallId, edit.filePath, 'before') }, + } : undefined, + after: hasAfter ? { + uri: afterUri, + content: { uri: buildSessionDbUri(sessionUriStr, edit.toolCallId, edit.filePath, 'after') }, + } : undefined, + diff: (edit.addedLines !== undefined || edit.removedLines !== undefined) + ? { added: edit.addedLines, removed: edit.removedLines } + : undefined, + }); + } + } + result.push({ + session, + type: 'tool_complete', + toolCallId: d.toolCallId, + result: { + success: d.success, + pastTenseMessage: getPastTenseMessage(info.toolName, displayName, info.parameters, d.success), + content: content.length > 0 ? content : undefined, + error: d.error, + }, + isUserRequested: d.isUserRequested, + toolTelemetry: d.toolTelemetry !== undefined ? tryStringify(d.toolTelemetry) : undefined, + parentToolCallId: d.parentToolCallId, + }); + } else if (e.type === 'subagent.started') { + const d = (e as ISessionEventSubagentStarted).data; + result.push({ + session, + type: 'subagent_started', + toolCallId: d.toolCallId, + agentName: d.agentName, + agentDisplayName: d.agentDisplayName, + agentDescription: d.agentDescription, + }); + } else if (e.type === 'skill.invoked') { + const skillEvent = e as ISessionEventSkillInvoked; + const synth = synthesizeSkillToolCall(skillEvent.data, skillEvent.id); + result.push( + { session, type: 'tool_start', toolCallId: synth.toolCallId, toolName: synth.toolName, displayName: synth.displayName, invocationMessage: synth.invocationMessage }, + { session, type: 'tool_complete', toolCallId: synth.toolCallId, result: { success: true, pastTenseMessage: synth.pastTenseMessage } }, + ); + } + } + + return result; +} diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index 420c89725f01c..f3e711d199855 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -9,14 +9,24 @@ import { observableValue } from '../../../../base/common/observable.js'; import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js'; import { URI } from '../../../../base/common/uri.js'; import { type ISyncedCustomization } from '../../common/agentPluginManager.js'; -import { AgentSession, type AgentProvider, type IAgent, type IAgentAttachment, type IAgentCreateSessionConfig, type IAgentCreateSessionResult, type IAgentDescriptor, type IAgentModelInfo, type IAgentProgressEvent, type IAgentResolveSessionConfigParams, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type SessionHistoryEvent } from '../../common/agentService.js'; -import { ProtectedResourceMetadata, type ModelSelection } from '../../common/state/protocol/state.js'; +import { AgentSession, type AgentProvider, type AgentSignal, type IAgent, type IAgentAttachment, type IAgentCreateSessionConfig, type IAgentCreateSessionResult, type IAgentDescriptor, type IAgentModelInfo, type IAgentResolveSessionConfigParams, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata } from '../../common/agentService.js'; +import { buildSubagentTurnsFromHistory, buildTurnsFromHistory, type IHistoryRecord } from './historyRecordFixtures.js'; +import { ProtectedResourceMetadata, type FileEdit, type ModelSelection } from '../../common/state/protocol/state.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; -import { CustomizationStatus, ToolResultContentType, type CustomizationRef, type PendingMessage, type SessionCustomization, type ToolCallResult } from '../../common/state/sessionState.js'; +import { ActionType, type SessionAction } from '../../common/state/sessionActions.js'; +import { CustomizationStatus, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, parseSubagentSessionUri, type CustomizationRef, type PendingMessage, type SessionCustomization, type SessionInputRequest, type StringOrMarkdown, type ToolCallResult, type ToolResultContent, type Turn } from '../../common/state/sessionState.js'; /** Well-known auto-generated title used by the 'with-title' prompt. */ export const MOCK_AUTO_TITLE = 'Automatically generated title'; +function uriKey(session: URI): string { + // Build a stable key from raw URI fields without invoking `toString()`, + // which would mutate the URI's `_formatted` cache and break + // `assert.deepStrictEqual` comparisons in tests that capture the URI + // before it is observed elsewhere. + return `${session.scheme}://${session.authority}${session.path}${session.query ? '?' + session.query : ''}${session.fragment ? '#' + session.fragment : ''}`; +} + function mockProject(provider: AgentProvider) { return { uri: URI.from({ scheme: 'mock-project', path: `/${provider}` }), displayName: `Agent ${provider}` }; } @@ -26,13 +36,15 @@ function mockProject(provider: AgentProvider) { * for assertion and exposes {@link fireProgress} to inject progress events. */ export class MockAgent implements IAgent { - private readonly _onDidSessionProgress = new Emitter(); + private readonly _onDidSessionProgress = new Emitter(); readonly onDidSessionProgress = this._onDidSessionProgress.event; private readonly _models = observableValue(this, []); readonly models = this._models; private readonly _sessions = new Map(); private _nextId = 1; + /** Active turn IDs per session, captured from sendMessage(). */ + private readonly _activeTurnIds = new Map(); readonly sendMessageCalls: { session: URI; prompt: string; attachments?: readonly IAgentAttachment[] }[] = []; @@ -50,8 +62,13 @@ export class MockAgent implements IAgent { readonly onDidCustomizationsChange = this._onDidCustomizationsChange.event; getSessionCustomizations?: (session: URI) => Promise; - /** Configurable return value for getSessionMessages. */ - sessionMessages: SessionHistoryEvent[] = []; + /** + * Configurable session history. Tests construct {@link IHistoryRecord} + * entries (the agent-internal intermediate shape) and the mock converts + * them to {@link Turn}s on demand. Subagent URIs are routed to filtered + * subagent turns via {@link buildSubagentTurnsFromHistory}. + */ + sessionMessages: IHistoryRecord[] = []; /** Optional overrides applied to session metadata from listSessions. */ sessionMetadataOverrides: Partial> = {}; @@ -95,16 +112,23 @@ export class MockAgent implements IAgent { return { items: [] }; } - async sendMessage(session: URI, prompt: string, attachments?: IAgentAttachment[]): Promise { + async sendMessage(session: URI, prompt: string, attachments?: IAgentAttachment[], turnId?: string): Promise { this.sendMessageCalls.push({ session, prompt, attachments }); + if (turnId) { + this._activeTurnIds.set(uriKey(session), turnId); + } } setPendingMessages(session: URI, steeringMessage: PendingMessage | undefined, queuedMessages: readonly PendingMessage[]): void { this.setPendingMessagesCalls.push({ session, steeringMessage, queuedMessages }); } - async getSessionMessages(_session: URI): Promise { - return this.sessionMessages; + async getSessionMessages(session: URI): Promise { + const subagentInfo = parseSubagentSessionUri(session.toString()); + if (subagentInfo) { + return buildSubagentTurnsFromHistory(this.sessionMessages, subagentInfo.toolCallId, session.toString()); + } + return buildTurnsFromHistory(this.sessionMessages); } async disposeSession(session: URI): Promise { @@ -160,8 +184,21 @@ export class MockAgent implements IAgent { async shutdown(): Promise { } - fireProgress(event: IAgentProgressEvent): void { - this._onDidSessionProgress.fire(event); + /** + * Fires an {@link AgentSignal} on this agent. + */ + fireProgress(signal: AgentSignal): void { + this._onDidSessionProgress.fire(signal); + } + + /** + * Looks up the active turn id captured from the most recent + * {@link sendMessage} call for a given session. Returns `undefined` if + * the session has no active turn yet (e.g. tests that fire progress + * without first calling sendMessage). + */ + getActiveTurnId(session: URI): string | undefined { + return this._activeTurnIds.get(uriKey(session)); } fireCustomizationsChange(): void { @@ -186,7 +223,7 @@ export const PRE_EXISTING_SESSION_URI = AgentSession.uri('mock', 'pre-existing-s export class ScriptedMockAgent implements IAgent { readonly id: AgentProvider = 'mock'; - private readonly _onDidSessionProgress = new Emitter(); + private readonly _onDidSessionProgress = new Emitter(); readonly onDidSessionProgress = this._onDidSessionProgress.event; private readonly _models = observableValue(this, [{ provider: 'mock', id: 'mock-model', name: 'Mock Model', maxContextWindow: 128000, supportsVision: false }]); readonly models = this._models; @@ -198,7 +235,7 @@ export class ScriptedMockAgent implements IAgent { * Message history for the pre-existing session: a single user→assistant * turn with a tool call. */ - private readonly _preExistingMessages: SessionHistoryEvent[] = [ + private readonly _preExistingMessages: IHistoryRecord[] = [ { type: 'message', role: 'user', session: PRE_EXISTING_SESSION_URI, messageId: 'h-msg-1', content: 'What files are here?' }, { type: 'tool_start', session: PRE_EXISTING_SESSION_URI, toolCallId: 'h-tc-1', toolName: 'list_files', displayName: 'List Files', invocationMessage: 'Listing files...' }, { type: 'tool_complete', session: PRE_EXISTING_SESSION_URI, toolCallId: 'h-tc-1', result: { pastTenseMessage: 'Listed files', content: [{ type: ToolResultContentType.Text, text: 'file1.ts\nfile2.ts' }], success: true } satisfies ToolCallResult }, @@ -207,6 +244,8 @@ export class ScriptedMockAgent implements IAgent { // Track pending permission requests private readonly _pendingPermissions = new Map void>(); + // Track the active turn ID per session, captured from sendMessage(). + private readonly _activeTurnIds = new Map(); // Track pending abort callbacks for slow responses private readonly _pendingAborts = new Map void>(); @@ -295,7 +334,10 @@ export class ScriptedMockAgent implements IAgent { return { items: branches.map(branch => ({ value: branch, label: branch })) }; } - async sendMessage(session: URI, prompt: string, _attachments?: IAgentAttachment[]): Promise { + async sendMessage(session: URI, prompt: string, _attachments?: IAgentAttachment[], turnId?: string): Promise { + if (turnId) { + this._activeTurnIds.set(uriKey(session), turnId); + } switch (prompt) { case 'hello': this._fireSequence(session, [ @@ -339,9 +381,9 @@ export class ScriptedMockAgent implements IAgent { }; (async () => { await timeout(10); - this._onDidSessionProgress.fire(toolStartEvent); + this._fireLegacy(session, toolStartEvent); await timeout(5); - this._onDidSessionProgress.fire(toolReadyEvent); + this._fireLegacy(session, toolReadyEvent); })(); this._pendingPermissions.set('tc-perm-1', (approved) => { if (approved) { @@ -358,9 +400,9 @@ export class ScriptedMockAgent implements IAgent { // Fire tool_start + tool_ready with write permission for a regular file (should be auto-approved) (async () => { await timeout(10); - this._onDidSessionProgress.fire({ type: 'tool_start', session, toolCallId: 'tc-write-1', toolName: 'create', displayName: 'Create File', invocationMessage: 'Create file' }); + this._fireLegacy(session, { type: 'tool_start', session, toolCallId: 'tc-write-1', toolName: 'create', displayName: 'Create File', invocationMessage: 'Create file' }); await timeout(5); - this._onDidSessionProgress.fire({ type: 'tool_ready', session, toolCallId: 'tc-write-1', invocationMessage: 'Write src/app.ts', permissionKind: 'write', permissionPath: '/workspace/src/app.ts' }); + this._fireLegacy(session, { type: 'tool_ready', session, toolCallId: 'tc-write-1', invocationMessage: 'Write src/app.ts', permissionKind: 'write', permissionPath: '/workspace/src/app.ts' }); // Auto-approved writes resolve immediately — complete the tool and turn await timeout(10); this._fireSequence(session, [ @@ -375,9 +417,9 @@ export class ScriptedMockAgent implements IAgent { // Fire tool_start + tool_ready with write permission for .env (should be blocked) (async () => { await timeout(10); - this._onDidSessionProgress.fire({ type: 'tool_start', session, toolCallId: 'tc-write-env-1', toolName: 'create', displayName: 'Create File', invocationMessage: 'Create file' }); + this._fireLegacy(session, { type: 'tool_start', session, toolCallId: 'tc-write-env-1', toolName: 'create', displayName: 'Create File', invocationMessage: 'Create file' }); await timeout(5); - this._onDidSessionProgress.fire({ type: 'tool_ready', session, toolCallId: 'tc-write-env-1', invocationMessage: 'Write .env', permissionKind: 'write', permissionPath: '/workspace/.env', confirmationTitle: 'Write .env' }); + this._fireLegacy(session, { type: 'tool_ready', session, toolCallId: 'tc-write-env-1', invocationMessage: 'Write .env', permissionKind: 'write', permissionPath: '/workspace/.env', confirmationTitle: 'Write .env' }); })(); this._pendingPermissions.set('tc-write-env-1', (approved) => { if (approved) { @@ -394,9 +436,9 @@ export class ScriptedMockAgent implements IAgent { // Fire tool_start + tool_ready with shell permission for an allowed command (should be auto-approved) (async () => { await timeout(10); - this._onDidSessionProgress.fire({ type: 'tool_start', session, toolCallId: 'tc-shell-1', toolName: 'bash', displayName: 'Run Command', invocationMessage: 'Run command' }); + this._fireLegacy(session, { type: 'tool_start', session, toolCallId: 'tc-shell-1', toolName: 'bash', displayName: 'Run Command', invocationMessage: 'Run command' }); await timeout(5); - this._onDidSessionProgress.fire({ type: 'tool_ready', session, toolCallId: 'tc-shell-1', invocationMessage: 'ls -la', permissionKind: 'shell', toolInput: 'ls -la' }); + this._fireLegacy(session, { type: 'tool_ready', session, toolCallId: 'tc-shell-1', invocationMessage: 'ls -la', permissionKind: 'shell', toolInput: 'ls -la' }); // Auto-approved shell commands resolve immediately await timeout(10); this._fireSequence(session, [ @@ -411,9 +453,9 @@ export class ScriptedMockAgent implements IAgent { // Fire tool_start + tool_ready with shell permission for a denied command (should require confirmation) (async () => { await timeout(10); - this._onDidSessionProgress.fire({ type: 'tool_start', session, toolCallId: 'tc-shell-deny-1', toolName: 'bash', displayName: 'Run Command', invocationMessage: 'Run command' }); + this._fireLegacy(session, { type: 'tool_start', session, toolCallId: 'tc-shell-deny-1', toolName: 'bash', displayName: 'Run Command', invocationMessage: 'Run command' }); await timeout(5); - this._onDidSessionProgress.fire({ type: 'tool_ready', session, toolCallId: 'tc-shell-deny-1', invocationMessage: 'rm -rf /', permissionKind: 'shell', toolInput: 'rm -rf /', confirmationTitle: 'Run in terminal' }); + this._fireLegacy(session, { type: 'tool_ready', session, toolCallId: 'tc-shell-deny-1', invocationMessage: 'rm -rf /', permissionKind: 'shell', toolInput: 'rm -rf /', confirmationTitle: 'Run in terminal' }); })(); this._pendingPermissions.set('tc-shell-deny-1', (approved) => { if (approved) { @@ -470,7 +512,7 @@ export class ScriptedMockAgent implements IAgent { // tool_ready once its deferred is in place. (async () => { await timeout(10); - this._onDidSessionProgress.fire({ + this._fireLegacy(session, { type: 'tool_start', session, toolCallId: 'tc-client-1', @@ -480,7 +522,7 @@ export class ScriptedMockAgent implements IAgent { toolClientId: 'test-client-tool', }); await timeout(5); - this._onDidSessionProgress.fire({ + this._fireLegacy(session, { type: 'tool_ready', session, toolCallId: 'tc-client-1', @@ -503,7 +545,7 @@ export class ScriptedMockAgent implements IAgent { // Fires tool_start with toolClientId followed by a permission request. (async () => { await timeout(10); - this._onDidSessionProgress.fire({ + this._fireLegacy(session, { type: 'tool_start', session, toolCallId: 'tc-client-perm-1', @@ -513,7 +555,7 @@ export class ScriptedMockAgent implements IAgent { toolClientId: 'test-client-tool', }); await timeout(5); - this._onDidSessionProgress.fire({ + this._fireLegacy(session, { type: 'tool_ready', session, toolCallId: 'tc-client-perm-1', @@ -593,7 +635,7 @@ export class ScriptedMockAgent implements IAgent { // git-driven diff path to pick this up. Format: `terminal-edit:`. const filePath = prompt.slice('terminal-edit:'.length); void (async () => { - this._onDidSessionProgress.fire({ type: 'tool_start', session, toolCallId: 'tc-term-edit-1', toolName: 'bash', displayName: 'Run Command', invocationMessage: 'Edit file via shell' }); + this._fireLegacy(session, { type: 'tool_start', session, toolCallId: 'tc-term-edit-1', toolName: 'bash', displayName: 'Run Command', invocationMessage: 'Edit file via shell' }); const fs = await import('fs/promises'); await fs.writeFile(filePath, 'edited-from-terminal\n'); this._fireSequence(session, [ @@ -622,7 +664,7 @@ export class ScriptedMockAgent implements IAgent { // When steering is set, consume it on the next tick if (steeringMessage) { timeout(20).then(() => { - this._onDidSessionProgress.fire({ type: 'steering_consumed', session, id: steeringMessage.id }); + this._fireLegacy(session, { type: 'steering_consumed', session, id: steeringMessage.id }); }); } } @@ -646,7 +688,7 @@ export class ScriptedMockAgent implements IAgent { } this.didCompleteToolCalls.add(key); // Fire tool_complete and resolve any pending callback. - this._onDidSessionProgress.fire({ + this._fireLegacy(session, { type: 'tool_complete', session, toolCallId, @@ -659,9 +701,13 @@ export class ScriptedMockAgent implements IAgent { } } - async getSessionMessages(session: URI): Promise { + async getSessionMessages(session: URI): Promise { + const subagentInfo = parseSubagentSessionUri(session.toString()); + if (subagentInfo) { + return buildSubagentTurnsFromHistory(this._preExistingMessages, subagentInfo.toolCallId, session.toString()); + } if (session.toString() === PRE_EXISTING_SESSION_URI.toString()) { - return this._preExistingMessages; + return buildTurnsFromHistory(this._preExistingMessages); } return []; } @@ -708,11 +754,438 @@ export class ScriptedMockAgent implements IAgent { this._onDidSessionProgress.dispose(); } - private _fireSequence(session: URI, events: IAgentProgressEvent[]): void { + private _fireSequence(session: URI, events: LegacyMockEvent[]): void { let delay = 0; for (const event of events) { delay += 10; - setTimeout(() => this._onDidSessionProgress.fire(event), delay); + setTimeout(() => this._fireLegacy(session, event), delay); } } + + /** Per-session translator state for {@link _fireLegacy}. Tracks the + * active markdown / reasoning response part ids so consecutive `delta` + * and `reasoning` events coalesce into append actions, mirroring the + * live emission rules of {@link CopilotAgentSession}. */ + private readonly _legacyState = new Map(); + + /** + * Translates a legacy test-event literal into one or more {@link AgentSignal} + * envelopes and fires them, mirroring the live emission rules of + * {@link CopilotAgentSession}. Allows test fixtures to stay close to the + * SDK-shaped event vocabulary while consumers see protocol actions. + */ + private _fireLegacy(session: URI, e: LegacyMockEvent): void { + const key = uriKey(session); + let state = this._legacyState.get(key); + if (!state) { + state = {}; + this._legacyState.set(key, state); + } + // Any non-text/reasoning event invalidates the active part ids so + // the next text/reasoning chunk allocates a fresh response part. + // This mirrors the live agent's behavior on tool_start, idle, etc. + // (`legacyToSignals` itself handles the delta↔reasoning toggling.) + if (e.type !== 'delta' && e.type !== 'message' && e.type !== 'reasoning') { + state.currentMarkdown = undefined; + state.currentReasoningPartId = undefined; + } + const signals = legacyToSignals(e, session, this._activeTurnIds.get(key) ?? 'mock-turn', state); + for (const signal of signals) { + this._onDidSessionProgress.fire(signal); + } + } +} + +// ============================================================================= +// Test-event helpers +// ============================================================================= + +/** + * Compact event vocabulary used by the scripted mock agent. Mirrors the + * fields of the historical `IAgentProgressEvent` union so existing test + * fixtures keep their shape while emission goes through {@link AgentSignal}. + */ +export type LegacyMockEvent = + | { type: 'delta'; session: URI; messageId: string; content: string; parentToolCallId?: string } + | { + type: 'message'; + session: URI; + role: 'user' | 'assistant'; + messageId: string; + content: string; + parentToolCallId?: string; + toolRequests?: readonly { toolCallId: string; name: string; arguments?: string; type?: 'function' | 'custom' }[]; + reasoningOpaque?: string; + reasoningText?: string; + encryptedContent?: string; + } + | { type: 'idle'; session: URI } + | { + type: 'tool_start'; + session: URI; + toolCallId: string; + toolName: string; + displayName: string; + invocationMessage: StringOrMarkdown; + toolInput?: string; + toolKind?: 'terminal' | 'subagent'; + language?: string; + toolClientId?: string; + subagentAgentName?: string; + subagentDescription?: string; + mcpServerName?: string; + mcpToolName?: string; + toolArguments?: string; + parentToolCallId?: string; + } + | { + type: 'tool_ready'; + session: URI; + toolCallId: string; + invocationMessage: StringOrMarkdown; + toolInput?: string; + confirmationTitle?: StringOrMarkdown; + permissionKind?: 'shell' | 'write' | 'mcp' | 'read' | 'url' | 'custom-tool'; + permissionPath?: string; + edits?: { items: FileEdit[] }; + } + | { type: 'tool_complete'; session: URI; toolCallId: string; result: ToolCallResult; parentToolCallId?: string } + | { type: 'tool_content_changed'; session: URI; toolCallId: string; content: ToolResultContent[] } + | { type: 'title_changed'; session: URI; title: string } + | { type: 'error'; session: URI; errorType: string; message: string; stack?: string } + | { type: 'usage'; session: URI; inputTokens?: number; outputTokens?: number; model?: string; cacheReadTokens?: number } + | { type: 'reasoning'; session: URI; content: string } + | { type: 'user_input_request'; session: URI; request: SessionInputRequest } + | { type: 'subagent_started'; session: URI; toolCallId: string; agentName: string; agentDisplayName: string; agentDescription?: string } + | { type: 'steering_consumed'; session: URI; id: string }; + +let _mockPartIdCounter = 0; + +/** + * Per-translator state used by {@link legacyToSignals} to coalesce + * consecutive `delta` and `reasoning` events into append actions, mirroring + * the live emission rules of {@link CopilotAgentSession}. Any event other + * than `delta` / `reasoning` (e.g. `tool_start`, `tool_complete`, `idle`, + * `error`) invalidates the active part ids so the next text/reasoning + * chunk allocates a fresh response part — same semantics as the live + * agent. + */ +export interface ILegacySignalState { + /** Active markdown part id and the message id it's keyed to. */ + currentMarkdown?: { messageId: string; partId: string }; + /** Active reasoning part id. */ + currentReasoningPartId?: string; } + +/** + * Converts a {@link LegacyMockEvent} into one or more {@link AgentSignal}s. + * + * If a {@link state} is provided, consecutive `delta` events with the same + * `messageId` and consecutive `reasoning` events coalesce into append + * actions ({@link ActionType.SessionDelta} / {@link ActionType.SessionReasoning}) + * the same way live agents emit them. Without {@link state}, each call is + * stateless and every text/reasoning event allocates a fresh response part. + * + * Tests that expect specific partId behaviour should use + * {@link IAgentActionSignal} envelopes directly via {@link MockAgent.fireProgress}. + */ +export function legacyToSignals(e: LegacyMockEvent, session: URI, turnId: string, state?: ILegacySignalState): AgentSignal[] { + const sessionStr = session.toString(); + switch (e.type) { + case 'delta': + case 'message': { + if (e.type === 'message' && e.role !== 'assistant') { + return []; + } + const content = e.type === 'delta' ? e.content : e.content; + if (!content) { + return []; + } + const messageId = e.type === 'delta' ? e.messageId : e.messageId; + // Reasoning is invalidated by any non-reasoning event so the + // next reasoning chunk starts a fresh part. + if (state) { + state.currentReasoningPartId = undefined; + } + // Coalesce: same messageId as the current markdown part ⇒ append. + if (state?.currentMarkdown && state.currentMarkdown.messageId === messageId) { + return [{ + kind: 'action', session, parentToolCallId: e.parentToolCallId, action: { + type: ActionType.SessionDelta, + session: sessionStr, + turnId, + partId: state.currentMarkdown.partId, + content, + }, + }]; + } + const partId = `mock-md-${++_mockPartIdCounter}`; + if (state) { + state.currentMarkdown = { messageId, partId }; + } + const action: SessionAction = { + type: ActionType.SessionResponsePart, + session: sessionStr, + turnId, + part: { kind: ResponsePartKind.Markdown, id: partId, content }, + }; + return [{ kind: 'action', session, action, parentToolCallId: e.parentToolCallId }]; + } + case 'reasoning': { + // Markdown is invalidated by any non-markdown event so the next + // text chunk starts a fresh part. + if (state) { + state.currentMarkdown = undefined; + } + if (state?.currentReasoningPartId) { + return [{ + kind: 'action', session, action: { + type: ActionType.SessionReasoning, + session: sessionStr, + turnId, + partId: state.currentReasoningPartId, + content: e.content, + }, + }]; + } + const partId = `mock-rs-${++_mockPartIdCounter}`; + if (state) { + state.currentReasoningPartId = partId; + } + return [{ + kind: 'action', session, action: { + type: ActionType.SessionResponsePart, + session: sessionStr, + turnId, + part: { kind: ResponsePartKind.Reasoning, id: partId, content: e.content }, + } + }]; + } + case 'idle': + return [{ kind: 'action', session, action: { type: ActionType.SessionTurnComplete, session: sessionStr, turnId } }]; + case 'title_changed': + return [{ kind: 'action', session, action: { type: ActionType.SessionTitleChanged, session: sessionStr, title: e.title } }]; + case 'error': + return [{ + kind: 'action', session, action: { + type: ActionType.SessionError, + session: sessionStr, + turnId, + error: { errorType: e.errorType, message: e.message, stack: e.stack }, + } + }]; + case 'usage': + return [{ + kind: 'action', session, action: { + type: ActionType.SessionUsage, + session: sessionStr, + turnId, + usage: { inputTokens: e.inputTokens, outputTokens: e.outputTokens, model: e.model, cacheReadTokens: e.cacheReadTokens }, + } + }]; + case 'user_input_request': + return [{ + kind: 'action', session, action: { + type: ActionType.SessionInputRequested, + session: sessionStr, + request: e.request, + } + }]; + case 'tool_content_changed': + return [{ + kind: 'action', session, action: { + type: ActionType.SessionToolCallContentChanged, + session: sessionStr, + turnId, + toolCallId: e.toolCallId, + content: e.content, + } + }]; + case 'tool_start': { + const meta: Record = { toolKind: e.toolKind, language: e.language }; + if (e.subagentAgentName) { + meta.subagentAgentName = e.subagentAgentName; + } + if (e.subagentDescription) { + meta.subagentDescription = e.subagentDescription; + } + const signals: AgentSignal[] = [{ + kind: 'action', session, parentToolCallId: e.parentToolCallId, action: { + type: ActionType.SessionToolCallStart, + session: sessionStr, + turnId, + toolCallId: e.toolCallId, + toolName: e.toolName, + displayName: e.displayName, + toolClientId: e.toolClientId, + _meta: meta, + } + }]; + // For client tools, do NOT auto-ready — the tool waits for an + // explicit `tool_ready` event from the test fixture, mirroring the + // live SDK behaviour. + if (!e.toolClientId) { + signals.push({ + kind: 'action', session, parentToolCallId: e.parentToolCallId, action: { + type: ActionType.SessionToolCallReady, + session: sessionStr, + turnId, + toolCallId: e.toolCallId, + invocationMessage: e.invocationMessage, + toolInput: e.toolInput, + confirmed: ToolCallConfirmationReason.NotNeeded, + } + }); + } + return signals; + } + case 'tool_ready': + return [{ + kind: 'pending_confirmation', + session, + // `toolName`/`displayName` are not used downstream of the + // signal — `SessionToolCallReadyAction` does not carry them + // and the reducer reads them from the existing tool-call + // state set by an earlier `tool_start`. Use empty placeholders + // so the legacy event type doesn't need to grow new fields. + state: { + status: ToolCallStatus.PendingConfirmation, + toolCallId: e.toolCallId, + toolName: '', + displayName: '', + invocationMessage: e.invocationMessage, + toolInput: e.toolInput, + confirmationTitle: e.confirmationTitle, + edits: e.edits, + }, + permissionKind: e.permissionKind, + permissionPath: e.permissionPath, + }]; + case 'tool_complete': + return [{ + kind: 'action', session, parentToolCallId: e.parentToolCallId, action: { + type: ActionType.SessionToolCallComplete, + session: sessionStr, + turnId, + toolCallId: e.toolCallId, + result: e.result, + } + }]; + case 'subagent_started': + return [{ + kind: 'subagent_started', + session, + toolCallId: e.toolCallId, + agentName: e.agentName, + agentDisplayName: e.agentDisplayName, + agentDescription: e.agentDescription, + }]; + case 'steering_consumed': + return [{ kind: 'steering_consumed', session, id: e.id }]; + } +} + +/** + * Compact legacy view of an {@link AgentSignal} used by tests that grew up + * with the old `IAgentProgressEvent` vocabulary. Returns `undefined` for + * action signals that have no direct legacy analogue. + * + * Mirrors the inverse of {@link legacyToSignals} for the most common + * action types so tests can keep doing `progressEvents[i].type === 'X'`. + */ +export function signalToLegacyView(signal: AgentSignal): LegacyMockEvent | undefined { + if (signal.kind === 'pending_confirmation') { + return { + type: 'tool_ready', + session: signal.session, + toolCallId: signal.state.toolCallId, + invocationMessage: signal.state.invocationMessage, + toolInput: signal.state.toolInput, + confirmationTitle: signal.state.confirmationTitle, + permissionKind: signal.permissionKind, + permissionPath: signal.permissionPath, + edits: signal.state.edits, + }; + } + if (signal.kind === 'subagent_started') { + return { + type: 'subagent_started', + session: signal.session, + toolCallId: signal.toolCallId, + agentName: signal.agentName, + agentDisplayName: signal.agentDisplayName, + agentDescription: signal.agentDescription, + }; + } + if (signal.kind === 'steering_consumed') { + return { type: 'steering_consumed', session: signal.session, id: signal.id }; + } + const action = signal.action; + switch (action.type) { + case ActionType.SessionResponsePart: { + if (action.part.kind === ResponsePartKind.Markdown) { + return { type: 'delta', session: signal.session, messageId: action.part.id, content: action.part.content }; + } + if (action.part.kind === ResponsePartKind.Reasoning) { + return { type: 'reasoning', session: signal.session, content: action.part.content }; + } + return undefined; + } + case ActionType.SessionDelta: + return { type: 'delta', session: signal.session, messageId: action.partId, content: action.content }; + case ActionType.SessionReasoning: + return { type: 'reasoning', session: signal.session, content: action.content }; + case ActionType.SessionTurnComplete: + return { type: 'idle', session: signal.session }; + case ActionType.SessionTitleChanged: + return { type: 'title_changed', session: signal.session, title: action.title }; + case ActionType.SessionError: + return { type: 'error', session: signal.session, errorType: action.error.errorType, message: action.error.message, stack: action.error.stack }; + case ActionType.SessionUsage: + return { type: 'usage', session: signal.session, ...action.usage }; + case ActionType.SessionInputRequested: + return { type: 'user_input_request', session: signal.session, request: action.request }; + case ActionType.SessionToolCallContentChanged: + return { type: 'tool_content_changed', session: signal.session, toolCallId: action.toolCallId, content: action.content }; + case ActionType.SessionToolCallStart: { + const meta = (action._meta ?? {}) as Record; + return { + type: 'tool_start', + session: signal.session, + toolCallId: action.toolCallId, + toolName: action.toolName, + displayName: action.displayName, + invocationMessage: '', + toolClientId: action.toolClientId, + toolKind: meta.toolKind as 'terminal' | 'subagent' | undefined, + language: meta.language as string | undefined, + subagentAgentName: meta.subagentAgentName as string | undefined, + subagentDescription: meta.subagentDescription as string | undefined, + toolArguments: meta.toolArguments as string | undefined, + mcpServerName: meta.mcpServerName as string | undefined, + mcpToolName: meta.mcpToolName as string | undefined, + parentToolCallId: signal.parentToolCallId, + }; + } + case ActionType.SessionToolCallReady: + return { + type: 'tool_ready', + session: signal.session, + toolCallId: action.toolCallId, + invocationMessage: action.invocationMessage, + toolInput: action.toolInput, + confirmationTitle: action.confirmationTitle, + edits: action.edits, + }; + case ActionType.SessionToolCallComplete: + return { + type: 'tool_complete', + session: signal.session, + toolCallId: action.toolCallId, + result: action.result, + parentToolCallId: signal.parentToolCallId, + }; + } + return undefined; +} + diff --git a/src/vs/platform/hover/browser/hoverService.ts b/src/vs/platform/hover/browser/hoverService.ts index cfb53e2e686ae..e0a22ac57eea7 100644 --- a/src/vs/platform/hover/browser/hoverService.ts +++ b/src/vs/platform/hover/browser/hoverService.ts @@ -642,6 +642,16 @@ export class HoverService extends Disposable implements IHoverService { if (isMouseDown || hoverPreparation) { return; } + // Clean up stale reference if the hover was dismissed externally + if (hoverWidget?.isDisposed) { + hoverWidget = undefined; + } + // If focus is returning from a dismissed hover (e.g. Esc) or + // from window reactivation (e.g. Alt-tab), don't re-show. + const fromHover = isHTMLElement(e.relatedTarget) && e.relatedTarget.closest('.monaco-hover'); + if (fromHover || !e.relatedTarget) { + return; + } if (!eventIsRelatedToTarget(e, targetElement)) { return; // Do not show hover when the focus is on another hover target } diff --git a/src/vs/platform/hover/test/browser/hoverService.test.ts b/src/vs/platform/hover/test/browser/hoverService.test.ts index 63672be05eec0..a26814c75e4c4 100644 --- a/src/vs/platform/hover/test/browser/hoverService.test.ts +++ b/src/vs/platform/hover/test/browser/hoverService.test.ts @@ -13,6 +13,7 @@ import { TestInstantiationService } from '../../../instantiation/test/common/ins import { IConfigurationService } from '../../../configuration/common/configuration.js'; import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; import { HoverService } from '../../browser/hoverService.js'; +import { IHoverService, WorkbenchHoverDelegate } from '../../browser/hover.js'; import { HoverWidget } from '../../browser/hoverWidget.js'; import { IContextMenuService } from '../../../contextview/browser/contextView.js'; import { IKeybindingService } from '../../../keybinding/common/keybinding.js'; @@ -79,6 +80,7 @@ suite('HoverService', () => { }); hoverService = store.add(instantiationService.createInstance(HoverService)); + instantiationService.stub(IHoverService, hoverService); }); // #region Helper functions @@ -500,6 +502,50 @@ suite('HoverService', () => { hover.dispose(); }); + + test('should not re-show hover on focus when relatedTarget is from a dismissed hover', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const target = createTarget(); + const delegate = store.add(instantiationService.createInstance(WorkbenchHoverDelegate, 'element', undefined, {})); + store.add(hoverService.setupManagedHover(delegate, target, 'Test')); + + // Show hover explicitly + target.dispatchEvent(new FocusEvent('focus', { bubbles: true, relatedTarget: document.body })); + await timeout(500); + const hoversBefore = fixture.querySelectorAll('.monaco-hover'); + assert.ok(hoversBefore.length > 0, 'Hover should be visible after focus'); + + // Dismiss via hoverService (simulates Esc / external dismissal) + hoverService.hideHover(true); + await timeout(0); + + // Simulate focus returning from the hover element + const hoverElement = document.createElement('div'); + hoverElement.classList.add('monaco-hover'); + target.dispatchEvent(new FocusEvent('focus', { bubbles: true, relatedTarget: hoverElement })); + await timeout(500); + + const hoversAfter = fixture.querySelectorAll('.monaco-hover'); + assert.strictEqual(hoversAfter.length, 0, 'Hover should not re-show when focus comes from dismissed hover'); + })); + + test('should not re-show hover on focus when relatedTarget is null (window reactivation)', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const target = createTarget(); + const delegate = store.add(instantiationService.createInstance(WorkbenchHoverDelegate, 'element', undefined, {})); + store.add(hoverService.setupManagedHover(delegate, target, 'Test')); + + // Show hover via focus and dismiss externally + target.dispatchEvent(new FocusEvent('focus', { bubbles: true, relatedTarget: document.body })); + await timeout(500); + hoverService.hideHover(true); + await timeout(0); + + // Simulate focus from window reactivation (relatedTarget is null) + target.dispatchEvent(new FocusEvent('focus', { bubbles: true, relatedTarget: null })); + await timeout(500); + + const hovers = fixture.querySelectorAll('.monaco-hover'); + assert.strictEqual(hovers.length, 0, 'Hover should not re-show on window reactivation'); + })); }); suite('showDelayedHover', () => { diff --git a/src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts b/src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts index 8de7fdcc25ce3..40da9e7c979a5 100644 --- a/src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts +++ b/src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts @@ -414,6 +414,7 @@ export class ShellIntegrationAddon extends Disposable implements IShellIntegrati // when instant prompt is enabled though. If this does end up being a problem we could pass // a type flag through the capability calls const [command, ...args] = data.split(';'); + this._logService.trace(`ShellIntegrationAddon#_doHandleFinalTermSequence: received sequence ${command}`); this._markSequenceSeen(command); switch (command) { case FinalTermOscPt.PromptStart: @@ -477,6 +478,7 @@ export class ShellIntegrationAddon extends Disposable implements IShellIntegrati // Pass the sequence along to the capability const argsIndex = data.indexOf(';'); const command = argsIndex === -1 ? data : data.substring(0, argsIndex); + this._logService.trace(`ShellIntegrationAddon#_doHandleVSCodeSequence: received sequence ${command}`); this._markSequenceSeen(command); // Cast to strict checked index access const args: (string | undefined)[] = argsIndex === -1 ? [] : data.substring(argsIndex + 1).split(';'); diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index 1dfe1bd7a9d99..44b01a4bfee19 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -54,7 +54,7 @@ src/vs/sessions/contrib/chat/browser/ ├── customizationHarnessService.ts # Sessions harness service (accepts any content-provider-backed session type) └── promptsService.ts # AgenticPromptsService (CLI user roots) src/vs/sessions/contrib/sessions/browser/ -├── aiCustomizationShortcutsWidget.ts # Shortcuts widget +├── aiCustomizationShortcutsWidget.ts # Sidebar shortcuts widget with header overview action └── customizationsToolbar.contribution.ts # Sidebar customization links ``` @@ -228,6 +228,10 @@ Skills that are directly invoked by UI elements (toolbar buttons, menu items) ar Counts shown in the sidebar (per-link badges and the header total in `AICustomizationShortcutsWidget`) are driven by the same `IAICustomizationItemsModel` singleton (`workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts`) that feeds the customizations editor's list widget. The model owns the per-active-harness `ProviderCustomizationItemSource` cache and exposes per-section `IObservable`; sidebar consumers `read` `.length` from those observables. There is exactly one discovery path, so editor and sidebar counts cannot diverge. McpServers and Plugins use their own service observables (`IMcpService.servers`, `IAgentPluginService.plugins`) directly. +### Sidebar Overview Entrypoint + +The Agents sidebar `AICustomizationShortcutsWidget` exposes a home action in the Customizations header. The header label remains the collapse toggle, while the separate icon-only action opens the AI Customization management editor and calls `showWelcomePage()` so users can return to the overview/welcome page from any customization section. The action lives in the header, rather than inside the collapsible list content, so it remains visible and is not clipped by the sidebar's scrollable layout. + ### Item Badges `IAICustomizationListItem.badge` is an optional string that renders as a small inline tag next to the item name (same visual style as the MCP "Bridged" badge). For context instructions, this badge shows the raw `applyTo` pattern (e.g. a glob like `**/*.ts`), while the tooltip (`badgeTooltip`) explains the behavior. For skills with UI integrations, the badge reads "UI Integration" with a tooltip describing which UI surface invokes the skill. The badge text is also included in search filtering. diff --git a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts index afb0167d7f3ca..a0cc7a2d1893a 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts +++ b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts @@ -35,7 +35,7 @@ import { URI } from '../../../../base/common/uri.js'; import { isWindows, isMacintosh } from '../../../../base/common/platform.js'; import { UpdateHoverWidget } from './updateHoverWidget.js'; import { ChatEntitlement, ChatEntitlementService, IChatEntitlementService } from '../../../../workbench/services/chat/common/chatEntitlementService.js'; -import { ChatStatusDashboard } from '../../../../workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.js'; +import { ChatStatusDashboard, IChatStatusDashboardOptions } from '../../../../workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { getAccountProfileImageUrl, getAccountTitleBarBadgeKey, getAccountTitleBarState, resolveAccountInfo } from '../../../browser/accountTitleBarState.js'; @@ -53,6 +53,14 @@ const SessionsTitleBarAccountWidgetAction = 'sessions.action.titleBarAccountWidg const SessionsTitleBarUpdateWidgetAction = 'sessions.action.titleBarUpdateWidget'; const SESSIONS_ACCOUNT_TITLEBAR_PANEL_WIDTH = 360; +const PERSONALIZE_ACTION_IDS: readonly string[] = [ + 'workbench.action.openSettings', + 'workbench.action.openGlobalKeybindings', + 'workbench.action.selectTheme', +]; +const SIGN_OUT_ACTION_ID = 'workbench.action.agenticSignOut'; +const SIGN_IN_ACTION_ID = 'workbench.action.agenticSignIn'; + function shouldHideSessionsTitleBarUpdateWidget(type: StateType): boolean { return type === StateType.Uninitialized || type === StateType.Idle @@ -242,6 +250,17 @@ MenuRegistry.appendMenuItem(AccountMenu, { order: 2, }); +// Keyboard Shortcuts (hidden on phone — no keybindings UI on mobile) +MenuRegistry.appendMenuItem(AccountMenu, { + command: { + id: 'workbench.action.openGlobalKeybindings', + title: localize('sessionsAccountMenu.keyboardShortcuts', "Keyboard Shortcuts"), + }, + when: IsPhoneLayoutContext.negate(), + group: '2_settings', + order: 3, +}); + // Update actions registerUpdateMenuItems(AccountMenu, '3_updates'); @@ -524,35 +543,47 @@ class TitleBarAccountWidget extends BaseActionViewItem { private createCombinedPanelContent(panelStore: DisposableStore): HTMLElement { const panel = $('div.sessions-account-titlebar-panel'); + + // Build the menu actions once and partition them. + const menu = this.menuService.createMenu(AccountMenu, this.contextKeyService); + const rawActions: IAction[] = []; + fillInActionBarActions(menu.getActions(), rawActions); + menu.dispose(); + const partitioned = this.partitionMenuActions(rawActions); + + // Header: account label + sign-out icon. const headerSection = append(panel, $('.sessions-account-titlebar-panel-header')); const title = append(headerSection, $('div.sessions-account-titlebar-panel-title')); title.textContent = this.getPanelHeaderLabel(); - const headerActions = this.getHeaderActions(); - if (headerActions.length > 0) { + if (partitioned.signOut) { const headerActionsContainer = append(headerSection, $('.sessions-account-titlebar-panel-header-actions')); - for (const action of headerActions) { - const button = append(headerActionsContainer, $('button.sessions-account-titlebar-panel-header-action', { type: 'button' })) as HTMLButtonElement; - button.disabled = !action.enabled; - button.setAttribute('aria-label', action.tooltip || action.label); - button.title = action.tooltip || action.label; - button.classList.add(...ThemeIcon.asClassNameArray(this.getHeaderActionIcon(action))); - - panelStore.add(addDisposableListener(button, EventType.CLICK, async event => { - event.preventDefault(); - event.stopPropagation(); - this.hoverService.hideHover(true); - this.clickPanelDisposable.clear(); - await Promise.resolve(action.run()); - })); + this.createPanelButton(headerActionsContainer, partitioned.signOut, panelStore, { + classNames: ['sessions-account-titlebar-panel-header-action'], + icon: this.getHeaderActionIcon(partitioned.signOut), + }); + } + + // Personalize section. + if (partitioned.personalize.length > 0) { + const personalizeId = 'sessions-account-personalize-title'; + const personalizeSection = append(panel, $('section.sessions-account-titlebar-panel-section', { 'aria-labelledby': personalizeId })); + const personalizeHeading = append(personalizeSection, $('div.sessions-account-titlebar-panel-section-title', { id: personalizeId })); + personalizeHeading.textContent = localize('sessionsAccountMenu.personalize', "Personalize"); + const personalizeActionsContainer = append(personalizeSection, $('.sessions-account-titlebar-panel-actions')); + for (const action of partitioned.personalize) { + this.createPanelButton(personalizeActionsContainer, action, panelStore, { + classNames: ['sessions-account-titlebar-panel-action', 'with-icon'], + icon: this.getPersonalizeActionIcon(action), + includeLabel: true, + }); } } - const actions = this.getPanelActions(); - if (actions.length > 0) { + // Other panel actions (sign-in, etc.) — only render if there's at least one non-separator action. + if (partitioned.other.some(a => !(a instanceof Separator))) { const actionsSection = append(panel, $('.sessions-account-titlebar-panel-actions')); let lastWasSeparator = true; - - for (const action of actions) { + for (const action of partitioned.other) { if (action instanceof Separator) { if (!lastWasSeparator) { append(actionsSection, $('.sessions-account-titlebar-panel-separator')); @@ -560,27 +591,27 @@ class TitleBarAccountWidget extends BaseActionViewItem { } continue; } - lastWasSeparator = false; - const button = append(actionsSection, $('button.sessions-account-titlebar-panel-action', { type: 'button' })) as HTMLButtonElement; - button.disabled = !action.enabled; - button.setAttribute('aria-label', action.tooltip || action.label); - button.classList.toggle('checked', !!action.checked); - append(button, ...renderLabelWithIcons(action.label)); - - panelStore.add(addDisposableListener(button, EventType.CLICK, async event => { - event.preventDefault(); - event.stopPropagation(); - this.hoverService.hideHover(true); - this.clickPanelDisposable.clear(); - await Promise.resolve(action.run()); - })); + this.createPanelButton(actionsSection, action, panelStore, { + classNames: ['sessions-account-titlebar-panel-action'], + includeLabel: true, + checked: !!action.checked, + }); } } + // Subscription / Copilot dashboard. const contentSection = append(panel, $('.sessions-account-titlebar-panel-content')); if (this.shouldShowCopilotDashboardHover()) { - append(contentSection, this.createCopilotHoverContent()); + const subscriptionId = 'sessions-account-subscription-title'; + const subscriptionSection = append(contentSection, $('section.sessions-account-titlebar-panel-section.subscription', { 'aria-labelledby': subscriptionId })); + const subscriptionHeader = append(subscriptionSection, $('.sessions-account-titlebar-panel-section-header')); + const subscriptionHeading = append(subscriptionHeader, $('div.sessions-account-titlebar-panel-section-title', { id: subscriptionId })); + subscriptionHeading.textContent = localize('sessionsAccountMenu.subscription', "Subscription"); + // Render the dashboard's title header (plan name + manage / CTA actions) + // directly into our section header row via the dashboard's public API. + const dashboard = this.createCopilotHoverContent({ titleHeaderContainer: subscriptionHeader }); + append(subscriptionSection, dashboard); } else if (!this.isAccountLoading) { const summary = append(contentSection, $('.sessions-account-titlebar-panel-summary')); summary.textContent = this.lastState.ariaLabel; @@ -589,52 +620,102 @@ class TitleBarAccountWidget extends BaseActionViewItem { return panel; } - private getPanelHeaderLabel(): string { - if (this.accountName) { - return localize('signedInAsHeader', "Signed in as {0}", this.accountName); + private partitionMenuActions(rawActions: IAction[]): { signOut: IAction | undefined; personalize: IAction[]; other: IAction[] } { + let signOut: IAction | undefined; + const personalizeMap = new Map(); + const other: IAction[] = []; + + const pushSeparator = () => { + // Collapse runs and skip leading separators so groups whose only + // items get filtered (e.g. update.*) don't leave orphans behind. + if (other.length === 0 || other[other.length - 1] instanceof Separator) { + return; + } + other.push(new Separator()); + }; + + for (const action of rawActions) { + if (action instanceof Separator) { + pushSeparator(); + continue; + } + if (action.id === SIGN_OUT_ACTION_ID) { + signOut = action; + continue; + } + if (PERSONALIZE_ACTION_IDS.includes(action.id)) { + personalizeMap.set(action.id, action); + continue; + } + if (action.id.startsWith('update.')) { + continue; + } + if (this.isAccountLoading && action.id === SIGN_IN_ACTION_ID) { + continue; + } + other.push(action); } - if (this.isAccountLoading) { - return localize('loadingAccountHeader', "Loading Account..."); + // Trim trailing separator left after filtering. + if (other.length > 0 && other[other.length - 1] instanceof Separator) { + other.pop(); } - return localize('accountMenuHeaderFallback', "Account"); + // Preserve canonical personalize order. + const personalize = PERSONALIZE_ACTION_IDS + .map(id => personalizeMap.get(id)) + .filter((a): a is IAction => !!a); + + return { signOut, personalize, other }; } - private getHeaderActions(): IAction[] { - const menu = this.menuService.createMenu(AccountMenu, this.contextKeyService); - const rawActions: IAction[] = []; - fillInActionBarActions(menu.getActions(), rawActions); - menu.dispose(); + private createPanelButton( + parent: HTMLElement, + action: IAction, + panelStore: DisposableStore, + options: { classNames: readonly string[]; icon?: ThemeIcon; includeLabel?: boolean; checked?: boolean }, + ): HTMLButtonElement { + const button = append(parent, $('button', { type: 'button' })) as HTMLButtonElement; + button.classList.add(...options.classNames); + button.disabled = !action.enabled; + button.setAttribute('aria-label', action.tooltip || action.label); + if (options.checked) { + button.classList.add('checked'); + } - const themeAction = rawActions.find(action => !(action instanceof Separator) && action.id === 'workbench.action.selectTheme'); - const settingsAction = rawActions.find(action => !(action instanceof Separator) && action.id === 'workbench.action.openSettings'); - const signOutAction = rawActions.find(action => !(action instanceof Separator) && action.id === 'workbench.action.agenticSignOut'); + if (options.icon && options.includeLabel) { + const iconElement = append(button, $('span.sessions-account-titlebar-panel-action-icon')); + iconElement.classList.add(...ThemeIcon.asClassNameArray(options.icon)); + const labelElement = append(button, $('span.sessions-account-titlebar-panel-action-label')); + append(labelElement, ...renderLabelWithIcons(action.label)); + } else if (options.icon) { + button.title = action.tooltip || action.label; + button.classList.add(...ThemeIcon.asClassNameArray(options.icon)); + } else { + append(button, ...renderLabelWithIcons(action.label)); + } - return [themeAction, settingsAction, signOutAction].filter((action): action is IAction => !!action); - } + panelStore.add(addDisposableListener(button, EventType.CLICK, async event => { + event.preventDefault(); + event.stopPropagation(); + this.hoverService.hideHover(true); + this.clickPanelDisposable.clear(); + await Promise.resolve(action.run()); + })); - private getPanelActions(): IAction[] { - const menu = this.menuService.createMenu(AccountMenu, this.contextKeyService); - const rawActions: IAction[] = []; - fillInActionBarActions(menu.getActions(), rawActions); - menu.dispose(); + return button; + } - return rawActions.filter(action => { - if (action instanceof Separator) { - return true; - } + private getPanelHeaderLabel(): string { + if (this.accountName) { + return localize('signedInAsHeader', "Signed in as {0}", this.accountName); + } - // Hide sign-in while account is still loading - if (this.isAccountLoading && action.id === 'workbench.action.agenticSignIn') { - return false; - } + if (this.isAccountLoading) { + return localize('loadingAccountHeader', "Loading Account..."); + } - return action.id !== 'workbench.action.agenticSignOut' - && action.id !== 'workbench.action.openSettings' - && action.id !== 'workbench.action.selectTheme' - && !action.id.startsWith('update.'); - }); + return localize('accountMenuHeaderFallback', "Account"); } private getHeaderActionIcon(action: IAction): ThemeIcon { @@ -643,18 +724,31 @@ class TitleBarAccountWidget extends BaseActionViewItem { return Codicon.symbolColor; case 'workbench.action.openSettings': return Codicon.settingsGear; - case 'workbench.action.agenticSignOut': + case SIGN_OUT_ACTION_ID: return Codicon.signOut; default: return Codicon.circleLargeFilled; } } + private getPersonalizeActionIcon(action: IAction): ThemeIcon { + switch (action.id) { + case 'workbench.action.openSettings': + return Codicon.settingsGear; + case 'workbench.action.openGlobalKeybindings': + return Codicon.keyboard; + case 'workbench.action.selectTheme': + return Codicon.symbolColor; + default: + return Codicon.circleLargeFilled; + } + } + private shouldShowCopilotDashboardHover(): boolean { return !this.chatEntitlementService.sentiment.hidden && !!this.accountName; } - private createCopilotHoverContent(): HTMLElement { + private createCopilotHoverContent(extraOptions?: Partial): HTMLElement { const store = new DisposableStore(); this.copilotDashboardStore.value = store; const dashboardElement = ChatStatusDashboard.instantiateInContents(this.instantiationService, store, { @@ -662,6 +756,8 @@ class TitleBarAccountWidget extends BaseActionViewItem { disableModelSelection: true, disableProviderOptions: true, disableCompletionsSnooze: true, + disableQuickSettingsCollapsible: true, + ...extraOptions, }); store.add(disposableWindowInterval(mainWindow, () => { diff --git a/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css b/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css index efedb5b7a4fe9..722e04c108044 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css +++ b/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css @@ -202,7 +202,8 @@ align-items: center; gap: 6px; min-height: 28px; - padding: 6px 8px 0; + padding: 6px 8px; + border-bottom: 1px solid var(--vscode-menu-separatorBackground, var(--vscode-disabledForeground)); } .agent-sessions-workbench .sessions-account-titlebar-panel-title { @@ -289,6 +290,102 @@ padding: 2px 0; } +.agent-sessions-workbench .sessions-account-titlebar-panel-section { + display: flex; + flex-direction: column; + padding: 6px 0 0; +} + +/* The embedded chat status dashboard ships dividers/HRs and header borders + intended for its standalone tooltip presentation. The section headings + provide the only visual grouping we want here. */ +.agent-sessions-workbench .sessions-account-titlebar-panel-section .chat-status-bar-entry-tooltip hr { + display: none; +} + +.agent-sessions-workbench .sessions-account-titlebar-panel-section .chat-status-bar-entry-tooltip > div.header, +.agent-sessions-workbench .sessions-account-titlebar-panel-section .chat-status-bar-entry-tooltip .contribution > div.header { + border-bottom: none; + padding-bottom: 0; +} + +.agent-sessions-workbench .sessions-account-titlebar-panel-section-title { + padding: 4px 8px 2px; + font-size: 12px; + font-weight: 500; + line-height: 18px; + color: var(--vscode-descriptionForeground); +} + +/* Subscription section: place "Subscription" label and the dashboard's + plan-name + manage-action header on a single right-aligned row. + The dashboard's `div.header` is reparented into this row at runtime, + so it's no longer matched by chatStatus.css rules. */ +.agent-sessions-workbench .sessions-account-titlebar-panel-section-header { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 10px 2px 8px; +} + +.agent-sessions-workbench .sessions-account-titlebar-panel-section-header .sessions-account-titlebar-panel-section-title { + flex: 1 1 auto; + min-width: 0; + padding: 0; +} + +.agent-sessions-workbench .sessions-account-titlebar-panel-section-header > div.header { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + line-height: 18px; +} + +.agent-sessions-workbench .sessions-account-titlebar-panel-section-header > div.header .header-label { + flex: 0 0 auto; + color: var(--vscode-descriptionForeground); + font-weight: 400; + font-size: 11px; +} + +.agent-sessions-workbench .sessions-account-titlebar-panel-section-header > div.header .monaco-action-bar { + flex: 0 0 auto; +} + +.agent-sessions-workbench .sessions-account-titlebar-panel-action.with-icon { + display: flex; + align-items: center; + gap: 6px; + height: 24px; +} + +.agent-sessions-workbench .sessions-account-titlebar-panel-action-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + flex: 0 0 auto; + color: var(--vscode-descriptionForeground); + /* `!important` to override `.codicon`'s own `font-size` rule. */ + font-size: 14px !important; +} + +.agent-sessions-workbench .sessions-account-titlebar-panel-action.with-icon:hover .sessions-account-titlebar-panel-action-icon, +.agent-sessions-workbench .sessions-account-titlebar-panel-action.with-icon:focus-visible .sessions-account-titlebar-panel-action-icon { + color: inherit; +} + +.agent-sessions-workbench .sessions-account-titlebar-panel-action-label { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .agent-sessions-workbench .sessions-account-titlebar-panel-separator { height: 1px; margin: 5px 0; @@ -302,7 +399,7 @@ box-sizing: border-box; height: 24px; margin: 0 4px; - padding: 0 16px; + padding: 0 6px; border: none; border-radius: var(--vscode-cornerRadius-medium); background: transparent; diff --git a/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css b/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css index 7504b73941699..ab6e830ac94b3 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css +++ b/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css @@ -135,9 +135,9 @@ padding: 0 8px; } -.sessions-account-titlebar-panel-content .chat-status-bar-entry-tooltip .collapsible-header { - margin-top: 0; - padding: 8px 10px 8px 8px; +.sessions-account-titlebar-panel-content .chat-status-bar-entry-tooltip .quota-indicator .quota-title { + font-size: 12px; + margin-bottom: 0; } .sessions-account-titlebar-panel-content .chat-status-bar-entry-tooltip .collapsible-inner { @@ -145,11 +145,20 @@ } .sessions-account-titlebar-panel-content .chat-status-bar-entry-tooltip .contribution .header { - padding: 0 10px 8px 8px; + padding: 0 10px 0 8px; + margin-bottom: 0; + line-height: 18px; + color: var(--vscode-descriptionForeground); +} + +.sessions-account-titlebar-panel-content .chat-status-bar-entry-tooltip div.header .monaco-action-bar { + color: var(--vscode-foreground); } .sessions-account-titlebar-panel-content .chat-status-bar-entry-tooltip .contribution .body { - padding: 0 10px 4px 8px; + padding: 0 10px 0 8px; + line-height: 16px; + color: var(--vscode-descriptionForeground); } .monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update .account-widget-update-button { diff --git a/src/vs/sessions/contrib/changes/browser/checksActions.ts b/src/vs/sessions/contrib/changes/browser/checksActions.ts index b7ccdf42a33d3..8543a6f34ed57 100644 --- a/src/vs/sessions/contrib/changes/browser/checksActions.ts +++ b/src/vs/sessions/contrib/changes/browser/checksActions.ts @@ -122,7 +122,7 @@ class ActiveSessionFailedCIChecksContextContribution extends Disposable implemen if (!pr) { return undefined; } - return gitHubService.getPullRequestCI(gitHubInfo.owner, gitHubInfo.repo, pr.headRef); + return gitHubService.getPullRequestCI(gitHubInfo.owner, gitHubInfo.repo, gitHubInfo.pullRequest.number, pr.headSha); }); this._register(bindContextKey(hasActiveSessionFailedCIChecks, contextKeyService, reader => { @@ -178,7 +178,7 @@ class FixCIChecksAction extends Action2 { return; } - const ciModel = gitHubService.getPullRequestCI(gitHubInfo.owner, gitHubInfo.repo, pr.headRef); + const ciModel = gitHubService.getPullRequestCI(gitHubInfo.owner, gitHubInfo.repo, gitHubInfo.pullRequest.number, pr.headSha); const checks = ciModel.checks.get(); const failedChecks = getFailedChecks(checks); if (failedChecks.length === 0) { diff --git a/src/vs/sessions/contrib/changes/browser/checksViewModel.ts b/src/vs/sessions/contrib/changes/browser/checksViewModel.ts index e9cd9b83579d1..52eb6781c9ec2 100644 --- a/src/vs/sessions/contrib/changes/browser/checksViewModel.ts +++ b/src/vs/sessions/contrib/changes/browser/checksViewModel.ts @@ -26,7 +26,7 @@ export class ChecksViewModel extends Disposable { return session?.resource; }); - const pullRequestInfoObs = derivedOpts<{ owner: string; repo: string; headRef: string } | undefined>({ + const pullRequestInfoObs = derivedOpts<{ owner: string; repo: string; prNumber: number; headSha: string } | undefined>({ equalsFn: structuralEquals }, reader => { const session = sessionManagementService.activeSession.read(reader); @@ -48,7 +48,8 @@ export class ChecksViewModel extends Disposable { return { owner: gitHubInfo.owner, repo: gitHubInfo.repo, - headRef: pr.headSha + prNumber: gitHubInfo.pullRequest.number, + headSha: pr.headSha }; }); @@ -61,7 +62,7 @@ export class ChecksViewModel extends Disposable { // Use the PR's headSha (commit SHA) rather than the branch // name so CI checks can still be fetched after branch deletion // (e.g. after the PR is merged). - const ciModel = gitHubService.getPullRequestCI(pullRequestInfo.owner, pullRequestInfo.repo, pullRequestInfo.headRef); + const ciModel = gitHubService.getPullRequestCI(pullRequestInfo.owner, pullRequestInfo.repo, pullRequestInfo.prNumber, pullRequestInfo.headSha); ciModel.refresh(); ciModel.startPolling(); reader.store.add({ dispose: () => ciModel.stopPolling() }); diff --git a/src/vs/sessions/contrib/codeReview/browser/codeReviewService.ts b/src/vs/sessions/contrib/codeReview/browser/codeReviewService.ts index 7f8cf951f56e7..5ea7fe526be2e 100644 --- a/src/vs/sessions/contrib/codeReview/browser/codeReviewService.ts +++ b/src/vs/sessions/contrib/codeReview/browser/codeReviewService.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; -import { autorun, IObservable, observableValue, transaction } from '../../../../base/common/observable.js'; +import { autorun, derivedOpts, IObservable, observableValue, transaction } from '../../../../base/common/observable.js'; +import { isEqual } from '../../../../base/common/resources.js'; import { URI, UriComponents } from '../../../../base/common/uri.js'; import { IRange, Range } from '../../../../editor/common/core/range.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; @@ -18,6 +19,7 @@ import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/co import { IGitHubService } from '../../github/browser/githubService.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionFileChange } from '../../../services/sessions/common/session.js'; +import { structuralEquals } from '../../../../base/common/equals.js'; // --- Types ------------------------------------------------------------------- @@ -235,9 +237,12 @@ interface ISessionReviewData { readonly state: ReturnType>; } +type IPullRequestReviewThreadsModel = ReturnType; + interface IPRSessionReviewData { readonly state: ReturnType>; readonly disposables: DisposableStore; + reviewThreadsModel?: IPullRequestReviewThreadsModel; initialized: boolean; } @@ -322,24 +327,41 @@ export class CodeReviewService extends Disposable implements ICodeReviewService this._loadFromStorage(); this._registerSessionListeners(); - this._register(autorun(reader => { - const activeSession = this._sessionsManagementService.activeSession.read(reader); - if (activeSession) { - this._ensurePRReviewInitialized(activeSession.resource); - } - })); + const activeSessionResourceObs = derivedOpts({ equalsFn: isEqual }, reader => { + return this._sessionsManagementService.activeSession.read(reader)?.resource; + }); - this._register(this._sessionsManagementService.onDidChangeSessions(e => { - const archived = e.changed.filter(s => s.isArchived.get()); - const nonArchived = e.changed.filter(s => !s.isArchived.get()); - // Initialize PR review for new/changed sessions - for (const session of [...e.added, ...nonArchived]) { - this._ensurePRReviewInitialized(session.resource); + const gitHubInfoObs = derivedOpts<{ owner: string; repo: string; pullRequestNumber: number } | undefined>({ equalsFn: structuralEquals }, reader => { + const gitHubInfo = this._sessionsManagementService.activeSession.read(reader)?.gitHubInfo.read(reader); + if (!gitHubInfo?.pullRequest) { + return undefined; } - // Dispose PR review for removed and archived sessions - for (const session of [...e.removed, ...archived]) { - this._disposePRReview(session.resource); + + return { + owner: gitHubInfo.owner, + repo: gitHubInfo.repo, + pullRequestNumber: gitHubInfo.pullRequest.number, + }; + }); + + this._register(autorun(reader => { + const activeSessionResource = activeSessionResourceObs.read(reader); + if (!activeSessionResource) { + return; } + + const gitHubInfo = gitHubInfoObs.read(reader); + const data = this._ensurePRReviewInitialized(activeSessionResource, gitHubInfo); + + // Initial fetch of review threads + data.reviewThreadsModel?.refresh().catch(err => { + this._logService.error('[CodeReviewService] Failed to fetch PR review threads:', err); + data.state.set({ kind: PRReviewStateKind.Error, reason: String(err) }, undefined); + }); + + // Start polling of review threads + data.reviewThreadsModel?.startPolling(); + reader.store.add({ dispose: () => data.reviewThreadsModel?.stopPolling() }); })); } @@ -540,20 +562,22 @@ export class CodeReviewService extends Disposable implements ICodeReviewService } private _registerSessionListeners(): void { - // Clean up when sessions change (archived/removed sessions, stale review versions) this._register(this._sessionsManagementService.onDidChangeSessions(e => { + let changed = false; + // Clean up reviews for removed/archived sessions for (const session of [...e.removed, ...e.changed.filter(s => s.isArchived.get())]) { + this._disposePRReview(session.resource); + const key = session.resource.toString(); const data = this._reviewsBySession.get(key); if (data) { data.state.set({ kind: CodeReviewStateKind.Idle }, undefined); - this._saveToStorage(); + changed = true; } } // Check for stale review versions when sessions change - let changed = false; for (const [key, data] of this._reviewsBySession) { const state = data.state.get(); if (state.kind !== CodeReviewStateKind.Result) { @@ -599,9 +623,9 @@ export class CodeReviewService extends Disposable implements ICodeReviewService const session = this._sessionsManagementService.getSession(sessionResource); const gitHubInfo = session?.gitHubInfo.get(); if (gitHubInfo?.pullRequest) { - const prModel = this._gitHubService.getPullRequest(gitHubInfo.owner, gitHubInfo.repo, gitHubInfo.pullRequest.number); + const reviewThreadsModel = this._gitHubService.getPullRequestReviewThreads(gitHubInfo.owner, gitHubInfo.repo, gitHubInfo.pullRequest.number); try { - await prModel.resolveThread(threadId); + await reviewThreadsModel.resolveThread(threadId); } catch (err) { this._logService.warn('[CodeReviewService] Failed to resolve PR thread on GitHub:', err); } @@ -652,27 +676,27 @@ export class CodeReviewService extends Disposable implements ICodeReviewService return data; } - private _ensurePRReviewInitialized(sessionResource: URI): void { + private _ensurePRReviewInitialized(sessionResource: URI, gitHubInfo: { owner: string; repo: string; pullRequestNumber: number } | undefined): IPRSessionReviewData { const data = this._getOrCreatePRReviewData(sessionResource); if (data.initialized) { - return; + return data; } const session = this._sessionsManagementService.getSession(sessionResource); - const gitHubInfo = session?.gitHubInfo.get(); - if (!gitHubInfo?.pullRequest) { - return; + if (!session || !gitHubInfo) { + return data; } data.initialized = true; data.state.set({ kind: PRReviewStateKind.Loading }, undefined); - const prModel = this._gitHubService.getPullRequest(gitHubInfo.owner, gitHubInfo.repo, gitHubInfo.pullRequest.number); - const workspace = session?.workspace.get(); + const reviewThreadsModel = this._gitHubService.getPullRequestReviewThreads(gitHubInfo.owner, gitHubInfo.repo, gitHubInfo.pullRequestNumber); + const workspace = session.workspace.get(); + data.reviewThreadsModel = reviewThreadsModel; - // Watch the PR model's review threads and map to local state + // Watch the PR review threads model and map to local state data.disposables.add(autorun(reader => { - const threads = prModel.reviewThreads.read(reader); + const threads = reviewThreadsModel.reviewThreads.read(reader); const converted = this._convertedPRCommentsBySession.get(sessionResource.toString()); const comments: IPRReviewComment[] = []; @@ -703,12 +727,7 @@ export class CodeReviewService extends Disposable implements ICodeReviewService data.state.set({ kind: PRReviewStateKind.Loaded, comments }, undefined); })); - // Start polling and initial fetch - prModel.refreshThreads().catch(err => { - this._logService.error('[CodeReviewService] Failed to fetch PR review threads:', err); - data.state.set({ kind: PRReviewStateKind.Error, reason: String(err) }, undefined); - }); - prModel.startPolling(); + return data; } private _disposePRReview(sessionResource: URI): void { @@ -717,6 +736,8 @@ export class CodeReviewService extends Disposable implements ICodeReviewService const data = this._prReviewBySession.get(key); if (data) { data.disposables.dispose(); + data.reviewThreadsModel?.stopPolling(); + this._prReviewBySession.delete(key); } } @@ -724,8 +745,10 @@ export class CodeReviewService extends Disposable implements ICodeReviewService override dispose(): void { for (const data of this._prReviewBySession.values()) { data.disposables.dispose(); + data.reviewThreadsModel?.stopPolling(); } this._prReviewBySession.clear(); + super.dispose(); } } diff --git a/src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts b/src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts index f5ca47e2c1e12..8b72aa3158962 100644 --- a/src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts +++ b/src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { Codicon } from '../../../../../base/common/codicons.js'; import { URI } from '../../../../../base/common/uri.js'; import { Range } from '../../../../../editor/common/core/range.js'; import { IObservable, observableValue } from '../../../../../base/common/observable.js'; @@ -17,8 +18,12 @@ import { ILogService, NullLogService } from '../../../../../platform/log/common/ import { InMemoryStorageService, IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IChatSessionFileChange, IChatSessionFileChange2 } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { IGitHubService } from '../../../github/browser/githubService.js'; -import { ISession } from '../../../../services/sessions/common/session.js'; -import { ICodeReviewService, CodeReviewService, CodeReviewStateKind, getCodeReviewFilesFromSessionChanges, getCodeReviewVersion } from '../../browser/codeReviewService.js'; +import { GitHubPRFetcher } from '../../../github/browser/fetchers/githubPRFetcher.js'; +import { GitHubPullRequestModel } from '../../../github/browser/models/githubPullRequestModel.js'; +import { GitHubPullRequestReviewThreadsModel } from '../../../github/browser/models/githubPullRequestReviewThreadsModel.js'; +import { IGitHubPRComment, IGitHubPullRequestReviewThread } from '../../../github/common/types.js'; +import { IGitHubInfo, ISession, ISessionWorkspace } from '../../../../services/sessions/common/session.js'; +import { ICodeReviewService, CodeReviewService, CodeReviewStateKind, PRReviewStateKind, getCodeReviewFilesFromSessionChanges, getCodeReviewVersion } from '../../browser/codeReviewService.js'; import { IActiveSession, ISessionsChangeEvent, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; suite('CodeReviewService', () => { @@ -27,6 +32,7 @@ suite('CodeReviewService', () => { let instantiationService: TestInstantiationService; let service: ICodeReviewService; let commandService: MockCommandService; + let gitHubService: MockGitHubService; let storageService: InMemoryStorageService; let sessionsManagement: MockSessionsManagementService; @@ -53,7 +59,6 @@ suite('CodeReviewService', () => { this.executeDeferred = { resolve: resolve as (v: unknown) => void, reject }; }); } - return this.result as T; } @@ -100,6 +105,7 @@ suite('CodeReviewService', () => { class MockSessionsManagementService extends mock() { private readonly _onDidChangeSessions: Emitter; + private readonly _activeSession: ReturnType>; override readonly onDidChangeSessions: Event; override readonly activeSession: IObservable; @@ -109,7 +115,8 @@ suite('CodeReviewService', () => { super(); this._onDidChangeSessions = disposables.add(new Emitter()); this.onDidChangeSessions = this._onDidChangeSessions.event; - this.activeSession = observableValue('test.activeSession', undefined); + this._activeSession = observableValue('test.activeSession', undefined); + this.activeSession = this._activeSession; } override getSession(resource: URI): ISession | undefined { @@ -121,17 +128,36 @@ suite('CodeReviewService', () => { (changes ?? []).map(c => ({ modifiedUri: c.modifiedUri ?? c.uri, originalUri: c.originalUri, insertions: c.insertions, deletions: c.deletions })) ); const isArchivedObs = observableValue('test.isArchived', archived); + const gitHubInfoObs = observableValue('test.gitHubInfo', undefined); + const workspaceObs = observableValue('test.workspace', { + label: 'workspace', + icon: Codicon.folder, + repositories: [{ uri: URI.file('/workspace'), workingDirectory: undefined, detail: undefined, baseBranchName: undefined }], + requiresWorkspaceTrust: false, + }); const sessionData: ISession = { sessionId: `test:${resource.toString()}`, resource, + workspace: workspaceObs, changes: changesObs, isArchived: isArchivedObs, - gitHubInfo: observableValue('test.gitHubInfo', undefined), + gitHubInfo: gitHubInfoObs, } as unknown as ISession; this._sessions.set(resource.toString(), sessionData); return sessionData; } + setGitHubInfo(resource: URI, gitHubInfo: IGitHubInfo | undefined): void { + const session = this._sessions.get(resource.toString()); + if (session) { + (session.gitHubInfo as ReturnType>).set(gitHubInfo, undefined); + } + } + + setActiveSession(resource: URI | undefined): void { + this._activeSession.set(resource ? this._sessions.get(resource.toString()) as IActiveSession | undefined : undefined, undefined); + } + updateSessionChanges(resource: URI, changes: readonly IChatSessionFileChange2[] | undefined): void { const session = this._sessions.get(resource.toString()); if (session) { @@ -160,13 +186,101 @@ suite('CodeReviewService', () => { } } + class MockReviewThreadsFetcher { + nextThreads: IGitHubPullRequestReviewThread[] = []; + getReviewThreadsCalls = 0; + resolveThreadCalls: { threadId: string }[] = []; + + async getReviewThreads(_owner: string, _repo: string, _prNumber: number): Promise { + this.getReviewThreadsCalls++; + return this.nextThreads; + } + + async postReviewComment(_owner: string, _repo: string, _prNumber: number, body: string, inReplyTo: number): Promise { + return makePRComment(inReplyTo, body); + } + + async resolveThread(_owner: string, _repo: string, threadId: string): Promise { + this.resolveThreadCalls.push({ threadId }); + } + } + + class MockGitHubPullRequestReviewThreadsModel extends GitHubPullRequestReviewThreadsModel { + startPollingCalls = 0; + stopPollingCalls = 0; + + override startPolling(intervalMs?: number): void { + this.startPollingCalls++; + super.startPolling(intervalMs); + } + + override stopPolling(): void { + this.stopPollingCalls++; + super.stopPolling(); + } + } + + class MockGitHubService extends mock() { + readonly legacyFetcher = new MockReviewThreadsFetcher(); + readonly reviewThreadsFetcher = new MockReviewThreadsFetcher(); + + private readonly _pullRequestModel: GitHubPullRequestModel; + private readonly _reviewThreadsModels = new Map(); + private readonly _reviewThreadsFetchers = new Map(); + + getPullRequestCalls = 0; + getPullRequestReviewThreadsCalls = 0; + + constructor(disposables: DisposableStore, logService: ILogService) { + super(); + this._pullRequestModel = disposables.add(new GitHubPullRequestModel('owner', 'repo', 1, this.legacyFetcher as unknown as GitHubPRFetcher, logService)); + this._reviewThreadsFetchers.set(this._key('owner', 'repo', 1), this.reviewThreadsFetcher); + } + + override getPullRequest(): GitHubPullRequestModel { + this.getPullRequestCalls++; + return this._pullRequestModel; + } + + override getPullRequestReviewThreads(owner: string, repo: string, prNumber: number): GitHubPullRequestReviewThreadsModel { + this.getPullRequestReviewThreadsCalls++; + return this.getReviewThreadsModel(owner, repo, prNumber); + } + + getReviewThreadsFetcher(owner: string, repo: string, prNumber: number): MockReviewThreadsFetcher { + const key = this._key(owner, repo, prNumber); + let fetcher = this._reviewThreadsFetchers.get(key); + if (!fetcher) { + fetcher = new MockReviewThreadsFetcher(); + this._reviewThreadsFetchers.set(key, fetcher); + } + return fetcher; + } + + getReviewThreadsModel(owner: string, repo: string, prNumber: number): MockGitHubPullRequestReviewThreadsModel { + const key = this._key(owner, repo, prNumber); + let model = this._reviewThreadsModels.get(key); + if (!model) { + model = store.add(new MockGitHubPullRequestReviewThreadsModel(owner, repo, prNumber, this.getReviewThreadsFetcher(owner, repo, prNumber) as unknown as GitHubPRFetcher, new NullLogService())); + this._reviewThreadsModels.set(key, model); + } + return model; + } + + private _key(owner: string, repo: string, prNumber: number): string { + return `${owner}/${repo}#${prNumber}`; + } + } + setup(() => { instantiationService = store.add(new TestInstantiationService()); commandService = new MockCommandService(); instantiationService.stub(ICommandService, commandService); - instantiationService.stub(ILogService, new NullLogService()); - instantiationService.stub(IGitHubService, new class extends mock() { }()); + const logService = new NullLogService(); + instantiationService.stub(ILogService, logService); + gitHubService = new MockGitHubService(store, logService); + instantiationService.stub(IGitHubService, gitHubService); sessionsManagement = new MockSessionsManagementService(store); instantiationService.stub(ISessionsManagementService, sessionsManagement); @@ -206,6 +320,112 @@ suite('CodeReviewService', () => { assert.notStrictEqual(obs1, obs2); }); + test('PR review state uses dedicated review threads model', async () => { + sessionsManagement.addSession(session); + sessionsManagement.setGitHubInfo(session, makeGitHubInfo()); + gitHubService.reviewThreadsFetcher.nextThreads = [makePRThread('thread-100', 'src/a.ts')]; + + sessionsManagement.setActiveSession(session); + await tick(); + await tick(); + + const state = service.getPRReviewState(session).get(); + assert.strictEqual(state.kind, PRReviewStateKind.Loaded); + if (state.kind === PRReviewStateKind.Loaded) { + assert.deepStrictEqual({ + comments: state.comments.map(comment => ({ id: comment.id, uri: comment.uri.toString(), body: comment.body, author: comment.author })), + getPullRequestCalls: gitHubService.getPullRequestCalls, + getPullRequestReviewThreadsCalls: gitHubService.getPullRequestReviewThreadsCalls, + legacyThreadRefreshes: gitHubService.legacyFetcher.getReviewThreadsCalls, + reviewThreadRefreshes: gitHubService.reviewThreadsFetcher.getReviewThreadsCalls, + }, { + comments: [{ id: 'thread-100', uri: 'file:///workspace/src/a.ts', body: 'Comment on src/a.ts', author: 'reviewer' }], + getPullRequestCalls: 0, + getPullRequestReviewThreadsCalls: 1, + legacyThreadRefreshes: 0, + reviewThreadRefreshes: 1, + }); + } + }); + + test('only active session PR review model is polled', async () => { + const session2 = URI.parse('test://session/2'); + sessionsManagement.addSession(session); + sessionsManagement.setGitHubInfo(session, makeGitHubInfo(1)); + sessionsManagement.addSession(session2); + sessionsManagement.setGitHubInfo(session2, makeGitHubInfo(2)); + gitHubService.getReviewThreadsFetcher('owner', 'repo', 1).nextThreads = [makePRThread('thread-100', 'src/a.ts')]; + gitHubService.getReviewThreadsFetcher('owner', 'repo', 2).nextThreads = [makePRThread('thread-200', 'src/b.ts')]; + + sessionsManagement.setActiveSession(session); + await tick(); + await tick(); + + const session1Model = gitHubService.getReviewThreadsModel('owner', 'repo', 1); + const session2Model = gitHubService.getReviewThreadsModel('owner', 'repo', 2); + assert.deepStrictEqual({ + session1StartPollingCalls: session1Model.startPollingCalls, + session1StopPollingCalls: session1Model.stopPollingCalls, + session2StartPollingCalls: session2Model.startPollingCalls, + session2StopPollingCalls: session2Model.stopPollingCalls, + }, { + session1StartPollingCalls: 1, + session1StopPollingCalls: 0, + session2StartPollingCalls: 0, + session2StopPollingCalls: 0, + }); + + sessionsManagement.setActiveSession(session2); + await tick(); + await tick(); + + assert.deepStrictEqual({ + session1StartPollingCalls: session1Model.startPollingCalls, + session1StopPollingCalls: session1Model.stopPollingCalls, + session2StartPollingCalls: session2Model.startPollingCalls, + session2StopPollingCalls: session2Model.stopPollingCalls, + }, { + session1StartPollingCalls: 1, + session1StopPollingCalls: 1, + session2StartPollingCalls: 1, + session2StopPollingCalls: 0, + }); + + sessionsManagement.setActiveSession(undefined); + await tick(); + + assert.deepStrictEqual({ + session1StartPollingCalls: session1Model.startPollingCalls, + session1StopPollingCalls: session1Model.stopPollingCalls, + session2StartPollingCalls: session2Model.startPollingCalls, + session2StopPollingCalls: session2Model.stopPollingCalls, + }, { + session1StartPollingCalls: 1, + session1StopPollingCalls: 1, + session2StartPollingCalls: 1, + session2StopPollingCalls: 1, + }); + }); + + test('resolvePRReviewThread uses dedicated review threads model', async () => { + sessionsManagement.addSession(session); + sessionsManagement.setGitHubInfo(session, makeGitHubInfo()); + + await service.resolvePRReviewThread(session, 'thread-100'); + + assert.deepStrictEqual({ + getPullRequestCalls: gitHubService.getPullRequestCalls, + getPullRequestReviewThreadsCalls: gitHubService.getPullRequestReviewThreadsCalls, + legacyResolveThreadCalls: gitHubService.legacyFetcher.resolveThreadCalls, + reviewResolveThreadCalls: gitHubService.reviewThreadsFetcher.resolveThreadCalls, + }, { + getPullRequestCalls: 0, + getPullRequestReviewThreadsCalls: 1, + legacyResolveThreadCalls: [], + reviewResolveThreadCalls: [{ threadId: 'thread-100' }], + }); + }); + // --- hasReview --- test('hasReview returns false when no review exists', () => { @@ -1028,6 +1248,41 @@ suite('CodeReviewService', () => { }); }); +function makeGitHubInfo(prNumber = 1): IGitHubInfo { + return { + owner: 'owner', + repo: 'repo', + pullRequest: { + number: prNumber, + uri: URI.parse(`https://github.com/owner/repo/pull/${prNumber}`), + }, + }; +} + +function makePRThread(id: string, path: string): IGitHubPullRequestReviewThread { + return { + id, + isResolved: false, + path, + line: 10, + comments: [makePRComment(100, `Comment on ${path}`, id)], + }; +} + +function makePRComment(id: number, body: string, threadId: string = String(id)): IGitHubPRComment { + return { + id, + body, + author: { login: 'reviewer', avatarUrl: '' }, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + path: undefined, + line: undefined, + threadId, + inReplyToId: undefined, + }; +} + function tick(): Promise { return new Promise(resolve => setTimeout(resolve, 0)); } diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts index afca3ffd79788..77e2cc937d968 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -1464,12 +1464,11 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } const chatIds = this._getChatIdsInGroup(sessionId); - if (chatIds.length <= 1) { - // Only one chat — delete the entire session - return this.deleteSession(sessionId); - } - // Find the chat matching the URI + // Find the chat matching the URI first, before deciding whether to + // delete the entire session. This prevents accidentally deleting the + // whole session when the grouping cache is stale and chatIds doesn't + // include the chat being closed. const chatId = chatIds.find(id => { const chat = this._sessionCache.get(this._localIdFromchatId(id)); return chat && chat.resource.toString() === chatUri.toString(); @@ -1478,6 +1477,11 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return; } + if (chatIds.length <= 1) { + // This is the only chat in the session — delete the entire session + return this.deleteSession(sessionId); + } + // Delete the underlying agent session first. // _refreshSessionCacheMultiChat handles the removed chat gracefully: // it detects the chat belongs to a group with remaining siblings and @@ -2377,19 +2381,34 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions const removedData: ICopilotChatSession[] = []; for (const [key, adapter] of this._sessionCache) { if (!currentKeys.has(key) && adapter instanceof AgentSessionAdapter) { - this._sessionCache.delete(key); removedData.push(adapter); cacheChanged = true; } } + // Resolve group IDs for removed sessions BEFORE removing them from the + // cache and invalidating grouping caches, so that child sessions are + // correctly mapped to their parent group. + let removedGroupIds: Map | undefined; + if (removedData.length > 0 && this._isMultiChatEnabled()) { + removedGroupIds = new Map(); + for (const removed of removedData) { + removedGroupIds.set(removed, this._getGroupIdForChat(removed)); + } + } + + // Now remove from cache and invalidate grouping caches + for (const removed of removedData) { + this._sessionCache.delete(removed.resource.toString()); + } + if (cacheChanged) { this._invalidateGroupingCaches(); } if (addedData.length > 0 || removedData.length > 0 || changedData.length > 0) { if (this._isMultiChatEnabled()) { - this._refreshSessionCacheMultiChat(addedData, removedData, changedData); + this._refreshSessionCacheMultiChat(addedData, removedData, changedData, removedGroupIds!); } else { this._onDidChangeSessions.fire({ added: addedData.map(d => this._chatToSession(d)), @@ -2404,12 +2423,8 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions addedData: ICopilotChatSession[], removedData: ICopilotChatSession[], changedData: ICopilotChatSession[], + removedGroupIds: Map, ): void { - // Track session group IDs for removed chats before they leave the cache - const removedGroupIds = new Map(); - for (const removed of removedData) { - removedGroupIds.set(removed, this._getGroupIdForChat(removed)); - } // Handle removed chats: if a removed chat belongs to a group with // remaining siblings, treat it as a changed event on the parent session diff --git a/src/vs/sessions/contrib/github/browser/github.contribution.ts b/src/vs/sessions/contrib/github/browser/github.contribution.ts index ce2d4ee14fa62..7af3a4471be7b 100644 --- a/src/vs/sessions/contrib/github/browser/github.contribution.ts +++ b/src/vs/sessions/contrib/github/browser/github.contribution.ts @@ -3,50 +3,127 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable } from '../../../../base/common/lifecycle.js'; -import { autorun } from '../../../../base/common/observable.js'; +import { structuralEquals } from '../../../../base/common/equals.js'; +import { Disposable, DisposableMap, toDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, derivedOpts } from '../../../../base/common/observable.js'; +import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { ISession } from '../../../services/sessions/common/session.js'; +import { ISessionsChangeEvent, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { getPullRequestKey } from '../common/utils.js'; import { GitHubService, IGitHubService } from './githubService.js'; -/** - * Immediately refreshes PR data when the active session changes so that - * CI checks and PR state are up-to-date without waiting for the next - * polling cycle. - */ -class GitHubActiveSessionRefreshContribution extends Disposable implements IWorkbenchContribution { +export class GitHubPullRequestPollingContribution extends Disposable implements IWorkbenchContribution { - static readonly ID = 'sessions.contrib.githubActiveSessionRefresh'; + static readonly ID = 'sessions.contrib.githubPullRequestPolling'; - private _lastSessionResource: URI | undefined; + private readonly _pullRequests = new DisposableMap(); constructor( - @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, @IGitHubService private readonly _gitHubService: IGitHubService, + @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, ) { super(); - this._register(autorun(reader => { - const session = this._sessionsManagementService.activeSession.read(reader); - if (!session) { - this._lastSessionResource = undefined; - return; + const activeSessionResourceObs = derivedOpts({ equalsFn: isEqual }, reader => { + return this._sessionsManagementService.activeSession.read(reader)?.resource; + }); + + const gitHubInfoObs = derivedOpts<{ owner: string; repo: string; pullRequestNumber: number } | undefined>({ equalsFn: structuralEquals }, reader => { + const gitHubInfo = this._sessionsManagementService.activeSession.read(reader)?.gitHubInfo.read(reader); + if (!gitHubInfo?.pullRequest) { + return undefined; } - if (this._lastSessionResource?.toString() === session.resource.toString()) { + + return { + owner: gitHubInfo.owner, + repo: gitHubInfo.repo, + pullRequestNumber: gitHubInfo.pullRequest.number, + }; + }); + + this._register(autorun(reader => { + const activeSessionResource = activeSessionResourceObs.read(reader); + const activeSession = this._sessionsManagementService.activeSession.read(reader); + if (!activeSessionResource || !activeSession || activeSession.isArchived.read(reader)) { return; } - this._lastSessionResource = session.resource; - const gitHubInfo = session.gitHubInfo.read(reader); - if (!gitHubInfo?.pullRequest) { + const gitHubInfo = gitHubInfoObs.read(reader); + if (!gitHubInfo) { return; } - const prModel = this._gitHubService.getPullRequest(gitHubInfo.owner, gitHubInfo.repo, gitHubInfo.pullRequest.number); + const prModel = this._gitHubService.getPullRequest(gitHubInfo.owner, gitHubInfo.repo, gitHubInfo.pullRequestNumber); prModel.refresh(); })); + + this._sessionsManagementService.onDidChangeSessions(this._onDidChangeSessions, this, this._store); + this._onDidChangeSessions({ added: this._sessionsManagementService.getSessions(), removed: [], changed: [] }); + } + + private _onDidChangeSessions(e: ISessionsChangeEvent): void { + // Added sessions + for (const session of e.added) { + // Archived + if (session.isArchived.get()) { + continue; + } + + this._startPolling(session); + } + + // Changes sessions + for (const session of e.changed) { + // Archived + if (session.isArchived.get()) { + this._stopPolling(session); + continue; + } + + this._startPolling(session); + } + + // Removed sessions + for (const session of e.removed) { + this._stopPolling(session); + } + } + + private _startPolling(session: ISession): void { + const gitHubInfo = session.gitHubInfo.get(); + if (!gitHubInfo || !gitHubInfo.pullRequest) { + return; + } + + const key = getPullRequestKey(gitHubInfo.owner, gitHubInfo.repo, gitHubInfo.pullRequest.number); + if (this._pullRequests.has(key)) { + return; + } + + const model = this._gitHubService.getPullRequest(gitHubInfo.owner, gitHubInfo.repo, gitHubInfo.pullRequest.number); + this._pullRequests.set(key, toDisposable(() => model.stopPolling())); + + model.startPolling(); + } + + private _stopPolling(session: ISession): void { + const gitHubInfo = session.gitHubInfo.get(); + if (!gitHubInfo || !gitHubInfo.pullRequest) { + return; + } + + const key = getPullRequestKey(gitHubInfo.owner, gitHubInfo.repo, gitHubInfo.pullRequest.number); + this._pullRequests.deleteAndDispose(key); + } + + override dispose(): void { + this._pullRequests.dispose(); + + super.dispose(); } } +registerWorkbenchContribution2(GitHubPullRequestPollingContribution.ID, GitHubPullRequestPollingContribution, WorkbenchPhase.AfterRestored); + registerSingleton(IGitHubService, GitHubService, InstantiationType.Delayed); -registerWorkbenchContribution2(GitHubActiveSessionRefreshContribution.ID, GitHubActiveSessionRefreshContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/github/browser/githubService.ts b/src/vs/sessions/contrib/github/browser/githubService.ts index e71533b759bce..61025047583ae 100644 --- a/src/vs/sessions/contrib/github/browser/githubService.ts +++ b/src/vs/sessions/contrib/github/browser/githubService.ts @@ -13,8 +13,10 @@ import { GitHubPRFetcher } from './fetchers/githubPRFetcher.js'; import { GitHubPRCIFetcher } from './fetchers/githubPRCIFetcher.js'; import { GitHubRepositoryModel } from './models/githubRepositoryModel.js'; import { GitHubPullRequestModel } from './models/githubPullRequestModel.js'; +import { GitHubPullRequestReviewThreadsModel } from './models/githubPullRequestReviewThreadsModel.js'; import { GitHubPullRequestCIModel } from './models/githubPullRequestCIModel.js'; import { GitHubChangesFetcher } from './fetchers/githubChangesFetcher.js'; +import { getPullRequestKey } from '../common/utils.js'; export interface IGitHubService { readonly _serviceBrand: undefined; @@ -32,10 +34,21 @@ export interface IGitHubService { getPullRequest(owner: string, repo: string, prNumber: number): GitHubPullRequestModel; /** - * Get or create a reactive model for CI checks on a pull request head ref. - * The model is cached by owner/repo/headRef key and disposed when the service is disposed. + * Dispose and remove cached models associated with a GitHub pull request, if they exist. */ - getPullRequestCI(owner: string, repo: string, headRef: string): GitHubPullRequestCIModel; + disposePullRequest(owner: string, repo: string, prNumber: number): void; + + /** + * Get or create a reactive model for review threads on a GitHub pull request. + * The model is cached by owner/repo/prNumber key and disposed when the service is disposed. + */ + getPullRequestReviewThreads(owner: string, repo: string, prNumber: number): GitHubPullRequestReviewThreadsModel; + + /** + * Get or create a reactive model for CI checks on a pull request head SHA. + * The model is cached by owner/repo/prNumber/headSha key and disposed when the service is disposed. + */ + getPullRequestCI(owner: string, repo: string, prNumber: number, headSha: string): GitHubPullRequestCIModel; /** * List files changed between two refs using the GitHub compare API. @@ -59,7 +72,8 @@ export class GitHubService extends Disposable implements IGitHubService { private readonly _repositories = this._register(new DisposableMap()); private readonly _pullRequests = this._register(new DisposableMap()); - private readonly _ciModels = this._register(new DisposableMap()); + private readonly _pullRequestReviewThreads = this._register(new DisposableMap()); + private readonly _ciModels = this._register(new DisposableMap>()); constructor( @IInstantiationService instantiationService: IInstantiationService, @@ -68,6 +82,7 @@ export class GitHubService extends Disposable implements IGitHubService { super(); this._apiClient = this._register(instantiationService.createInstance(GitHubApiClient)); + this._repoFetcher = new GitHubRepositoryFetcher(this._apiClient); this._changesFetcher = new GitHubChangesFetcher(this._apiClient); this._prFetcher = new GitHubPRFetcher(this._apiClient); @@ -86,7 +101,7 @@ export class GitHubService extends Disposable implements IGitHubService { } getPullRequest(owner: string, repo: string, prNumber: number): GitHubPullRequestModel { - const key = `${owner}/${repo}/${prNumber}`; + const key = getPullRequestKey(owner, repo, prNumber); let model = this._pullRequests.get(key); if (!model) { this._logService.trace(`${LOG_PREFIX} Creating PR model for ${key}`); @@ -96,13 +111,31 @@ export class GitHubService extends Disposable implements IGitHubService { return model; } - getPullRequestCI(owner: string, repo: string, headRef: string): GitHubPullRequestCIModel { - const key = `${owner}/${repo}/${headRef}`; - let model = this._ciModels.get(key); + getPullRequestReviewThreads(owner: string, repo: string, prNumber: number): GitHubPullRequestReviewThreadsModel { + const key = getPullRequestKey(owner, repo, prNumber); + let model = this._pullRequestReviewThreads.get(key); if (!model) { - this._logService.trace(`${LOG_PREFIX} Creating CI model for ${key}`); - model = new GitHubPullRequestCIModel(owner, repo, headRef, this._ciFetcher, this._logService); - this._ciModels.set(key, model); + this._logService.trace(`${LOG_PREFIX} Creating PR review threads model for ${key}`); + model = new GitHubPullRequestReviewThreadsModel(owner, repo, prNumber, this._prFetcher, this._logService); + this._pullRequestReviewThreads.set(key, model); + } + return model; + } + + getPullRequestCI(owner: string, repo: string, prNumber: number, headSha: string): GitHubPullRequestCIModel { + const key = getPullRequestKey(owner, repo, prNumber); + let models = this._ciModels.get(key); + if (!models) { + models = new DisposableMap(); + this._ciModels.set(key, models); + } + + let model = models.get(headSha); + if (!model) { + models.clearAndDisposeAll(); + this._logService.trace(`${LOG_PREFIX} Creating CI model for ${key}/${headSha}`); + model = new GitHubPullRequestCIModel(owner, repo, headSha, this._ciFetcher, this._logService); + models.set(headSha, model); } return model; } @@ -110,4 +143,20 @@ export class GitHubService extends Disposable implements IGitHubService { getChangedFiles(owner: string, repo: string, base: string, head: string): Promise { return this._changesFetcher.getChangedFiles(owner, repo, base, head); } + + disposePullRequest(owner: string, repo: string, prNumber: number): void { + const key = getPullRequestKey(owner, repo, prNumber); + + this._pullRequests.deleteAndDispose(key); + this._pullRequestReviewThreads.deleteAndDispose(key); + this._ciModels.deleteAndDispose(key); + } + + override dispose(): void { + this._pullRequests.dispose(); + this._pullRequestReviewThreads.dispose(); + this._ciModels.dispose(); + + super.dispose(); + } } diff --git a/src/vs/sessions/contrib/github/browser/models/githubPullRequestCIModel.ts b/src/vs/sessions/contrib/github/browser/models/githubPullRequestCIModel.ts index ba12e62e61111..df659a5ee0469 100644 --- a/src/vs/sessions/contrib/github/browser/models/githubPullRequestCIModel.ts +++ b/src/vs/sessions/contrib/github/browser/models/githubPullRequestCIModel.ts @@ -32,7 +32,7 @@ export class GitHubPullRequestCIModel extends Disposable { constructor( readonly owner: string, readonly repo: string, - readonly headRef: string, + readonly headSha: string, private readonly _fetcher: GitHubPRCIFetcher, private readonly _logService: ILogService, ) { @@ -46,14 +46,14 @@ export class GitHubPullRequestCIModel extends Disposable { */ async refresh(): Promise { try { - const response = await this._fetcher.getCheckRuns(this.owner, this.repo, this.headRef, this._checksEtag); + const response = await this._fetcher.getCheckRuns(this.owner, this.repo, this.headSha, this._checksEtag); if (response.statusCode === 200 && response.data) { this._checksEtag = response.etag; this._checks.set(response.data, undefined); this._overallStatus.set(computeOverallCIStatus(response.data), undefined); } } catch (err) { - this._logService.error(`${LOG_PREFIX} Failed to refresh CI checks for ${this.owner}/${this.repo}@${this.headRef}:`, err); + this._logService.error(`${LOG_PREFIX} Failed to refresh CI checks for ${this.owner}/${this.repo}@${this.headSha}:`, err); } } diff --git a/src/vs/sessions/contrib/github/browser/models/githubPullRequestModel.ts b/src/vs/sessions/contrib/github/browser/models/githubPullRequestModel.ts index 21c77de511bbf..31c6261afd2ea 100644 --- a/src/vs/sessions/contrib/github/browser/models/githubPullRequestModel.ts +++ b/src/vs/sessions/contrib/github/browser/models/githubPullRequestModel.ts @@ -7,7 +7,7 @@ import { RunOnceScheduler } from '../../../../../base/common/async.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { IObservable, observableValue, transaction } from '../../../../../base/common/observable.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; -import { IGitHubPRComment, IGitHubPullRequest, IGitHubPullRequestMergeability, IGitHubPullRequestReview, IGitHubPullRequestReviewThread } from '../../common/types.js'; +import { IGitHubPRComment, IGitHubPullRequest, IGitHubPullRequestMergeability, IGitHubPullRequestReview } from '../../common/types.js'; import { computeMergeability, GitHubPRFetcher } from '../fetchers/githubPRFetcher.js'; const LOG_PREFIX = '[GitHubPullRequestModel]'; @@ -30,9 +30,6 @@ export class GitHubPullRequestModel extends Disposable { private readonly _mergeability = observableValue(this, undefined); readonly mergeability: IObservable = this._mergeability; - private readonly _reviewThreads = observableValue(this, []); - readonly reviewThreads: IObservable = this._reviewThreads; - private readonly _pollScheduler: RunOnceScheduler; private _disposed = false; @@ -54,26 +51,7 @@ export class GitHubPullRequestModel extends Disposable { * `mergeability`, avoiding duplicate `GET /pulls/:number` calls per cycle. */ async refresh(): Promise { - await Promise.all([ - this._refreshPullRequestAndMergeability(), - this._refreshThreads(), - ]); - } - - /** - * Refresh only the review threads. - */ - async refreshThreads(): Promise { - await this._refreshThreads(); - } - - /** - * Post a reply to an existing review thread and refresh threads. - */ - async postReviewComment(body: string, inReplyTo: number): Promise { - const comment = await this._fetcher.postReviewComment(this.owner, this.repo, this.prNumber, body, inReplyTo); - await this._refreshThreads(); - return comment; + await this._refreshPullRequestAndMergeability(); } /** @@ -83,14 +61,6 @@ export class GitHubPullRequestModel extends Disposable { return this._fetcher.postIssueComment(this.owner, this.repo, this.prNumber, body); } - /** - * Resolve a review thread and refresh the thread list. - */ - async resolveThread(threadId: string): Promise { - await this._fetcher.resolveThread(this.owner, this.repo, threadId); - await this._refreshThreads(); - } - /** * Start periodic polling. Each cycle refreshes all PR data. */ @@ -150,15 +120,6 @@ export class GitHubPullRequestModel extends Disposable { } } - private async _refreshThreads(): Promise { - try { - const data = await this._fetcher.getReviewThreads(this.owner, this.repo, this.prNumber); - this._reviewThreads.set(data, undefined); - } catch (err) { - this._logService.error(`${LOG_PREFIX} Failed to refresh threads for PR #${this.prNumber}:`, err); - } - } - override dispose(): void { this._disposed = true; super.dispose(); diff --git a/src/vs/sessions/contrib/github/browser/models/githubPullRequestReviewThreadsModel.ts b/src/vs/sessions/contrib/github/browser/models/githubPullRequestReviewThreadsModel.ts new file mode 100644 index 0000000000000..918bd396b6591 --- /dev/null +++ b/src/vs/sessions/contrib/github/browser/models/githubPullRequestReviewThreadsModel.ts @@ -0,0 +1,96 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { RunOnceScheduler } from '../../../../../base/common/async.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IGitHubPRComment, IGitHubPullRequestReviewThread } from '../../common/types.js'; +import { GitHubPRFetcher } from '../fetchers/githubPRFetcher.js'; + +const LOG_PREFIX = '[GitHubPullRequestReviewThreadsModel]'; +const DEFAULT_POLL_INTERVAL_MS = 60_000; + +/** + * Reactive model for GitHub pull request review threads. Review threads are + * fetched through GraphQL, so they have a separate refresh and polling cadence + * from lightweight pull request metadata. + */ +export class GitHubPullRequestReviewThreadsModel extends Disposable { + + private readonly _reviewThreads = observableValue(this, []); + readonly reviewThreads: IObservable = this._reviewThreads; + + private readonly _pollScheduler: RunOnceScheduler; + private _disposed = false; + + constructor( + readonly owner: string, + readonly repo: string, + readonly prNumber: number, + private readonly _fetcher: GitHubPRFetcher, + private readonly _logService: ILogService, + ) { + super(); + + this._pollScheduler = this._register(new RunOnceScheduler(() => this._poll(), DEFAULT_POLL_INTERVAL_MS)); + } + + /** + * Refresh review thread data. + */ + async refresh(): Promise { + try { + const data = await this._fetcher.getReviewThreads(this.owner, this.repo, this.prNumber); + this._reviewThreads.set(data, undefined); + } catch (err) { + this._logService.error(`${LOG_PREFIX} Failed to refresh threads for PR #${this.prNumber}:`, err); + } + } + + /** + * Post a reply to an existing review thread and refresh threads. + */ + async postReviewComment(body: string, inReplyTo: number): Promise { + const comment = await this._fetcher.postReviewComment(this.owner, this.repo, this.prNumber, body, inReplyTo); + await this.refresh(); + return comment; + } + + /** + * Resolve a review thread and refresh the thread list. + */ + async resolveThread(threadId: string): Promise { + await this._fetcher.resolveThread(this.owner, this.repo, threadId); + await this.refresh(); + } + + /** + * Start periodic polling. Each cycle refreshes review thread data. + */ + startPolling(intervalMs: number = DEFAULT_POLL_INTERVAL_MS): void { + this._pollScheduler.cancel(); + this._pollScheduler.schedule(intervalMs); + } + + /** + * Stop periodic polling. + */ + stopPolling(): void { + this._pollScheduler.cancel(); + } + + private async _poll(): Promise { + await this.refresh(); + if (!this._disposed) { + this._pollScheduler.schedule(); + } + } + + override dispose(): void { + this._disposed = true; + super.dispose(); + } +} diff --git a/src/vs/sessions/contrib/github/common/utils.ts b/src/vs/sessions/contrib/github/common/utils.ts index b406554013b46..3fc3524214f53 100644 --- a/src/vs/sessions/contrib/github/common/utils.ts +++ b/src/vs/sessions/contrib/github/common/utils.ts @@ -24,3 +24,7 @@ export function toPRContentUri(fileName: string, params: IPullRequestContentUriP query: JSON.stringify({ ...params, fileName }) }); } + +export function getPullRequestKey(owner: string, repo: string, prNumber: number): string { + return `${owner}/${repo}/${prNumber}`; +} diff --git a/src/vs/sessions/contrib/github/test/browser/githubContribution.test.ts b/src/vs/sessions/contrib/github/test/browser/githubContribution.test.ts new file mode 100644 index 0000000000000..c69baf710e4bb --- /dev/null +++ b/src/vs/sessions/contrib/github/test/browser/githubContribution.test.ts @@ -0,0 +1,267 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; +import { DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { GitHubPullRequestPollingContribution } from '../../browser/github.contribution.js'; +import { GitHubPullRequestModel } from '../../browser/models/githubPullRequestModel.js'; +import { IGitHubService } from '../../browser/githubService.js'; +import { IChat, IGitHubInfo, ISession, ISessionCapabilities, ISessionFileChange, ISessionWorkspace, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { IActiveSession, ISessionsChangeEvent, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; + +suite('GitHubPullRequestPollingContribution', () => { + + const store = new DisposableStore(); + let sessionsManagementService: TestSessionsManagementService; + let gitHubService: TestGitHubService; + + setup(() => { + sessionsManagementService = new TestSessionsManagementService(store); + gitHubService = new TestGitHubService(); + }); + + teardown(() => store.clear()); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('starts polling existing and added pull request sessions', () => { + const existingSession = sessionsManagementService.addSession('existing', makeGitHubInfo(1)); + + store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService)); + + const addedSession = sessionsManagementService.addSession('added', makeGitHubInfo(2)); + sessionsManagementService.fireSessionsChanged({ added: [addedSession] }); + + assert.deepStrictEqual(gitHubService.snapshot(), { + 'owner/repo/1': { startPollingCalls: 1, stopPollingCalls: 0, disposeCalls: 0 }, + 'owner/repo/2': { startPollingCalls: 1, stopPollingCalls: 0, disposeCalls: 0 }, + }); + assert.strictEqual(existingSession.isArchived.get(), false); + }); + + test('stops polling when a session is archived, then resumes when unarchived', () => { + const session = sessionsManagementService.addSession('session', makeGitHubInfo(1)); + store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService)); + + sessionsManagementService.setArchived(session, true); + sessionsManagementService.fireSessionsChanged({ changed: [session] }); + + assert.deepStrictEqual(gitHubService.snapshot(), { + 'owner/repo/1': { startPollingCalls: 1, stopPollingCalls: 1, disposeCalls: 0 }, + }); + + sessionsManagementService.setArchived(session, false); + sessionsManagementService.fireSessionsChanged({ changed: [session] }); + + assert.deepStrictEqual(gitHubService.snapshot(), { + 'owner/repo/1': { startPollingCalls: 2, stopPollingCalls: 1, disposeCalls: 0 }, + }); + }); + + test('does not poll archived sessions until they are unarchived', () => { + const session = sessionsManagementService.addSession('session', makeGitHubInfo(1), true); + store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService)); + + assert.deepStrictEqual(gitHubService.snapshot(), {}); + + sessionsManagementService.setArchived(session, false); + sessionsManagementService.fireSessionsChanged({ changed: [session] }); + + assert.deepStrictEqual(gitHubService.snapshot(), { + 'owner/repo/1': { startPollingCalls: 1, stopPollingCalls: 0, disposeCalls: 0 }, + }); + }); + + test('stops polling tracked pull requests when disposed', () => { + const session = sessionsManagementService.addSession('session', makeGitHubInfo(1)); + const contribution = store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService)); + + contribution.dispose(); + + assert.deepStrictEqual(gitHubService.snapshot(), { + 'owner/repo/1': { startPollingCalls: 1, stopPollingCalls: 1, disposeCalls: 0 }, + }); + assert.strictEqual(session.isArchived.get(), false); + }); +}); + +class TestSessionsManagementService extends mock() { + + private readonly _onDidChangeSessions: Emitter; + private readonly _activeSession = observableValue('test.activeSession', undefined); + private readonly _sessions = new Map(); + + override readonly onDidChangeSessions: Event; + override readonly activeSession: IObservable = this._activeSession; + + constructor(disposables: DisposableStore) { + super(); + this._onDidChangeSessions = disposables.add(new Emitter()); + this.onDidChangeSessions = this._onDidChangeSessions.event; + } + + addSession(id: string, gitHubInfo: IGitHubInfo | undefined, archived = false): ISession { + const session = new TestSession(id, gitHubInfo, archived); + this._sessions.set(session.sessionId, session); + return session; + } + + removeSession(session: ISession): void { + this._sessions.delete(session.sessionId); + this.fireSessionsChanged({ removed: [session] }); + } + + setArchived(session: ISession, archived: boolean): void { + (session.isArchived as ReturnType>).set(archived, undefined); + } + + setGitHubInfo(session: ISession, gitHubInfo: IGitHubInfo | undefined): void { + (session.gitHubInfo as ReturnType>).set(gitHubInfo, undefined); + } + + override getSessions(): ISession[] { + return [...this._sessions.values()]; + } + + fireSessionsChanged(event?: Partial): void { + this._onDidChangeSessions.fire({ + added: event?.added ?? [], + removed: event?.removed ?? [], + changed: event?.changed ?? [], + }); + } +} + +class TestSession implements ISession { + + readonly sessionId: string; + readonly resource: URI; + readonly providerId = 'test'; + readonly sessionType = 'test'; + readonly icon = Codicon.comment; + readonly createdAt = new Date(0); + readonly title: ReturnType>; + readonly updatedAt: ReturnType>; + readonly status: ReturnType>; + readonly changes: ReturnType>; + readonly workspace: ReturnType>; + readonly modelId: ReturnType>; + readonly mode: ReturnType>; + readonly loading: ReturnType>; + readonly isArchived: ReturnType>; + readonly isRead: ReturnType>; + readonly description: ReturnType>; + readonly lastTurnEnd: ReturnType>; + readonly gitHubInfo: ReturnType>; + readonly chats: ReturnType>; + readonly mainChat: IChat; + readonly capabilities: ISessionCapabilities = { supportsMultipleChats: false }; + + constructor(id: string, gitHubInfo: IGitHubInfo | undefined, archived: boolean) { + this.sessionId = `test:${id}`; + this.resource = URI.from({ scheme: 'test', path: `/${id}` }); + this.title = observableValue(`test.title.${id}`, id); + this.updatedAt = observableValue(`test.updatedAt.${id}`, new Date(0)); + this.status = observableValue(`test.status.${id}`, SessionStatus.Completed); + this.changes = observableValue(`test.changes.${id}`, []); + this.workspace = observableValue(`test.workspace.${id}`, undefined); + this.modelId = observableValue(`test.modelId.${id}`, undefined); + this.mode = observableValue<{ readonly id: string; readonly kind: string } | undefined>(`test.mode.${id}`, undefined); + this.loading = observableValue(`test.loading.${id}`, false); + this.isArchived = observableValue(`test.isArchived.${id}`, archived); + this.isRead = observableValue(`test.isRead.${id}`, true); + this.description = observableValue(`test.description.${id}`, undefined); + this.lastTurnEnd = observableValue(`test.lastTurnEnd.${id}`, undefined); + this.gitHubInfo = observableValue(`test.gitHubInfo.${id}`, gitHubInfo); + this.mainChat = { + resource: this.resource, + createdAt: this.createdAt, + title: this.title, + updatedAt: this.updatedAt, + status: this.status, + changes: this.changes, + modelId: this.modelId, + mode: this.mode, + isArchived: this.isArchived, + isRead: this.isRead, + description: this.description, + lastTurnEnd: this.lastTurnEnd, + }; + this.chats = observableValue(`test.chats.${id}`, [this.mainChat]); + } +} + +class TestGitHubService extends mock() { + + private readonly _models = new Map(); + + override getPullRequest(owner: string, repo: string, prNumber: number): GitHubPullRequestModel { + return this._getModel(owner, repo, prNumber) as unknown as GitHubPullRequestModel; + } + + override disposePullRequest(owner: string, repo: string, prNumber: number): void { + this._getModel(owner, repo, prNumber).dispose(); + } + + snapshot(): Record { + const entries = [...this._models.entries()].map(([key, model]) => [key, { + startPollingCalls: model.startPollingCalls, + stopPollingCalls: model.stopPollingCalls, + disposeCalls: model.disposeCalls, + }] as const); + return Object.fromEntries(entries); + } + + private _getModel(owner: string, repo: string, prNumber: number): TestPullRequestModel { + const key = `${owner}/${repo}/${prNumber}`; + let model = this._models.get(key); + if (!model) { + model = new TestPullRequestModel(); + this._models.set(key, model); + } + return model; + } +} + +class TestPullRequestModel implements IDisposable { + + startPollingCalls = 0; + stopPollingCalls = 0; + disposeCalls = 0; + + startPolling(): void { + this.startPollingCalls++; + } + + stopPolling(): void { + this.stopPollingCalls++; + } + + refresh(): Promise { + return Promise.resolve(); + } + + dispose(): void { + this.disposeCalls++; + } +} + +function makeGitHubInfo(prNumber: number): IGitHubInfo { + return { + owner: 'owner', + repo: 'repo', + pullRequest: { + number: prNumber, + uri: URI.parse(`https://github.com/owner/repo/pull/${prNumber}`), + }, + }; +} diff --git a/src/vs/sessions/contrib/github/test/browser/githubModels.test.ts b/src/vs/sessions/contrib/github/test/browser/githubModels.test.ts index a8618eefec6c0..575d6682b6d77 100644 --- a/src/vs/sessions/contrib/github/test/browser/githubModels.test.ts +++ b/src/vs/sessions/contrib/github/test/browser/githubModels.test.ts @@ -8,6 +8,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; import { GitHubPullRequestModel } from '../../browser/models/githubPullRequestModel.js'; +import { GitHubPullRequestReviewThreadsModel } from '../../browser/models/githubPullRequestReviewThreadsModel.js'; import { GitHubPullRequestCIModel, parseWorkflowRunId } from '../../browser/models/githubPullRequestCIModel.js'; import { GitHubRepositoryModel } from '../../browser/models/githubRepositoryModel.js'; import { GitHubPRFetcher } from '../../browser/fetchers/githubPRFetcher.js'; @@ -32,10 +33,15 @@ class MockPRFetcher { nextPR: IGitHubPullRequest | undefined; nextReviews: IGitHubPullRequestReview[] = []; nextThreads: IGitHubPullRequestReviewThread[] = []; + getPullRequestCalls = 0; + getReviewsCalls = 0; + getReviewThreadsCalls = 0; postReviewCommentCalls: { body: string; inReplyTo: number }[] = []; postIssueCommentCalls: { body: string }[] = []; + resolveThreadCalls: { threadId: string }[] = []; async getPullRequest(_owner: string, _repo: string, _prNumber: number, _etag?: string): Promise<{ data: IGitHubPullRequest | undefined; statusCode: number; etag?: string }> { + this.getPullRequestCalls++; if (!this.nextPR) { throw new Error('No mock PR'); } @@ -43,10 +49,12 @@ class MockPRFetcher { } async getReviews(_owner: string, _repo: string, _prNumber: number, _etag?: string): Promise<{ data: readonly IGitHubPullRequestReview[] | undefined; statusCode: number; etag?: string }> { + this.getReviewsCalls++; return { data: this.nextReviews, statusCode: 200 }; } async getReviewThreads(_owner: string, _repo: string, _prNumber: number): Promise { + this.getReviewThreadsCalls++; return this.nextThreads; } @@ -60,8 +68,8 @@ class MockPRFetcher { return makeComment(998, body); } - async resolveThread(): Promise { - throw new Error('Not implemented'); + async resolveThread(_owner: string, _repo: string, threadId: string): Promise { + this.resolveThreadCalls.push({ threadId }); } } @@ -139,51 +147,121 @@ suite('GitHubPullRequestModel', () => { const model = store.add(new GitHubPullRequestModel('owner', 'repo', 1, mockFetcher as unknown as GitHubPRFetcher, logService)); assert.strictEqual(model.pullRequest.get(), undefined); assert.strictEqual(model.mergeability.get(), undefined); - assert.deepStrictEqual(model.reviewThreads.get(), []); }); - test('refresh populates all observables', async () => { + test('refresh populates pull request and mergeability without fetching review threads', async () => { const model = store.add(new GitHubPullRequestModel('owner', 'repo', 1, mockFetcher as unknown as GitHubPRFetcher, logService)); mockFetcher.nextPR = makePR(); mockFetcher.nextReviews = []; mockFetcher.nextThreads = [makeThread('thread-100', 'src/a.ts')]; await model.refresh(); - assert.strictEqual(model.pullRequest.get()?.number, 1); - assert.strictEqual(model.mergeability.get()?.canMerge, true); - assert.strictEqual(model.reviewThreads.get().length, 1); + + assert.deepStrictEqual({ + prNumber: model.pullRequest.get()?.number, + canMerge: model.mergeability.get()?.canMerge, + getPullRequestCalls: mockFetcher.getPullRequestCalls, + getReviewsCalls: mockFetcher.getReviewsCalls, + getReviewThreadsCalls: mockFetcher.getReviewThreadsCalls, + }, { + prNumber: 1, + canMerge: true, + getPullRequestCalls: 1, + getReviewsCalls: 1, + getReviewThreadsCalls: 0, + }); + }); + + test('postIssueComment calls fetcher', async () => { + const model = store.add(new GitHubPullRequestModel('owner', 'repo', 1, mockFetcher as unknown as GitHubPRFetcher, logService)); + + const comment = await model.postIssueComment('Great work!'); + assert.strictEqual(comment.body, 'Great work!'); + assert.strictEqual(mockFetcher.postIssueCommentCalls.length, 1); }); - test('refreshThreads only updates threads', async () => { + test('polling can be started and stopped', () => { const model = store.add(new GitHubPullRequestModel('owner', 'repo', 1, mockFetcher as unknown as GitHubPRFetcher, logService)); + // Just ensure no errors; actual polling behavior is timer-based + model.startPolling(60_000); + model.stopPolling(); + }); +}); + +suite('GitHubPullRequestReviewThreadsModel', () => { + + const store = new DisposableStore(); + let mockFetcher: MockPRFetcher; + const logService = new NullLogService(); + + setup(() => { + mockFetcher = new MockPRFetcher(); + }); + + teardown(() => store.clear()); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('initial state is empty', () => { + const model = store.add(new GitHubPullRequestReviewThreadsModel('owner', 'repo', 1, mockFetcher as unknown as GitHubPRFetcher, logService)); + assert.deepStrictEqual(model.reviewThreads.get(), []); + }); + + test('refresh updates only review threads', async () => { + const model = store.add(new GitHubPullRequestReviewThreadsModel('owner', 'repo', 1, mockFetcher as unknown as GitHubPRFetcher, logService)); mockFetcher.nextThreads = [makeThread('thread-100', 'src/a.ts'), makeThread('thread-200', 'src/b.ts')]; - await model.refreshThreads(); - assert.strictEqual(model.pullRequest.get(), undefined); // not refreshed - assert.strictEqual(model.reviewThreads.get().length, 2); + await model.refresh(); + + assert.deepStrictEqual({ + threads: model.reviewThreads.get().map(thread => thread.id), + getPullRequestCalls: mockFetcher.getPullRequestCalls, + getReviewsCalls: mockFetcher.getReviewsCalls, + getReviewThreadsCalls: mockFetcher.getReviewThreadsCalls, + }, { + threads: ['thread-100', 'thread-200'], + getPullRequestCalls: 0, + getReviewsCalls: 0, + getReviewThreadsCalls: 1, + }); }); test('postReviewComment calls fetcher and refreshes threads', async () => { - const model = store.add(new GitHubPullRequestModel('owner', 'repo', 1, mockFetcher as unknown as GitHubPRFetcher, logService)); - mockFetcher.nextThreads = []; + const model = store.add(new GitHubPullRequestReviewThreadsModel('owner', 'repo', 1, mockFetcher as unknown as GitHubPRFetcher, logService)); + mockFetcher.nextThreads = [makeThread('thread-100', 'src/a.ts')]; const comment = await model.postReviewComment('LGTM', 100); - assert.strictEqual(comment.body, 'LGTM'); - assert.strictEqual(mockFetcher.postReviewCommentCalls.length, 1); - assert.strictEqual(mockFetcher.postReviewCommentCalls[0].body, 'LGTM'); + + assert.deepStrictEqual({ + commentBody: comment.body, + postReviewCommentCalls: mockFetcher.postReviewCommentCalls, + threads: model.reviewThreads.get().map(thread => thread.id), + }, { + commentBody: 'LGTM', + postReviewCommentCalls: [{ body: 'LGTM', inReplyTo: 100 }], + threads: ['thread-100'], + }); }); - test('postIssueComment calls fetcher', async () => { - const model = store.add(new GitHubPullRequestModel('owner', 'repo', 1, mockFetcher as unknown as GitHubPRFetcher, logService)); + test('resolveThread calls fetcher and refreshes threads', async () => { + const model = store.add(new GitHubPullRequestReviewThreadsModel('owner', 'repo', 1, mockFetcher as unknown as GitHubPRFetcher, logService)); + mockFetcher.nextThreads = []; - const comment = await model.postIssueComment('Great work!'); - assert.strictEqual(comment.body, 'Great work!'); - assert.strictEqual(mockFetcher.postIssueCommentCalls.length, 1); + await model.resolveThread('thread-100'); + + assert.deepStrictEqual({ + resolveThreadCalls: mockFetcher.resolveThreadCalls, + getReviewThreadsCalls: mockFetcher.getReviewThreadsCalls, + threads: model.reviewThreads.get(), + }, { + resolveThreadCalls: [{ threadId: 'thread-100' }], + getReviewThreadsCalls: 1, + threads: [], + }); }); test('polling can be started and stopped', () => { - const model = store.add(new GitHubPullRequestModel('owner', 'repo', 1, mockFetcher as unknown as GitHubPRFetcher, logService)); - // Just ensure no errors; actual polling behavior is timer-based + const model = store.add(new GitHubPullRequestReviewThreadsModel('owner', 'repo', 1, mockFetcher as unknown as GitHubPRFetcher, logService)); model.startPolling(60_000); model.stopPolling(); }); diff --git a/src/vs/sessions/contrib/github/test/browser/githubService.test.ts b/src/vs/sessions/contrib/github/test/browser/githubService.test.ts index 4d3680de7deae..48118319f38d2 100644 --- a/src/vs/sessions/contrib/github/test/browser/githubService.test.ts +++ b/src/vs/sessions/contrib/github/test/browser/githubService.test.ts @@ -52,22 +52,72 @@ suite('GitHubService', () => { assert.notStrictEqual(model1, model2); }); + test('disposePullRequest removes cached pull request model', () => { + const model1 = service.getPullRequest('owner', 'repo', 1); + + service.disposePullRequest('owner', 'repo', 1); + + const model2 = service.getPullRequest('owner', 'repo', 1); + assert.notStrictEqual(model1, model2); + }); + + test('getPullRequestReviewThreads returns cached model for same key', () => { + const model1 = service.getPullRequestReviewThreads('owner', 'repo', 1); + const model2 = service.getPullRequestReviewThreads('owner', 'repo', 1); + assert.strictEqual(model1, model2); + }); + + test('getPullRequestReviewThreads returns different models for different PRs', () => { + const model1 = service.getPullRequestReviewThreads('owner', 'repo', 1); + const model2 = service.getPullRequestReviewThreads('owner', 'repo', 2); + assert.notStrictEqual(model1, model2); + }); + test('getPullRequestCI returns cached model for same key', () => { - const model1 = service.getPullRequestCI('owner', 'repo', 'abc123'); - const model2 = service.getPullRequestCI('owner', 'repo', 'abc123'); + const model1 = service.getPullRequestCI('owner', 'repo', 1, 'abc123'); + const model2 = service.getPullRequestCI('owner', 'repo', 1, 'abc123'); assert.strictEqual(model1, model2); }); + test('getPullRequestCI uses prNumber before the head ref', () => { + const model = service.getPullRequestCI('owner', 'repo', 1, 'abc123'); + + assert.strictEqual(model.headSha, 'abc123'); + }); + test('getPullRequestCI returns different models for different refs', () => { - const model1 = service.getPullRequestCI('owner', 'repo', 'abc'); - const model2 = service.getPullRequestCI('owner', 'repo', 'def'); + const model1 = service.getPullRequestCI('owner', 'repo', 1, 'abc'); + const model2 = service.getPullRequestCI('owner', 'repo', 1, 'def'); assert.notStrictEqual(model1, model2); }); + test('getPullRequestCI returns different models for different pull requests', () => { + const model1 = service.getPullRequestCI('owner', 'repo', 1, 'abc'); + const model2 = service.getPullRequestCI('owner', 'repo', 2, 'abc'); + assert.notStrictEqual(model1, model2); + }); + + test('getPullRequestCI only retains the current head ref model', () => { + const model1 = service.getPullRequestCI('owner', 'repo', 1, 'abc'); + service.getPullRequestCI('owner', 'repo', 1, 'def'); + + const model2 = service.getPullRequestCI('owner', 'repo', 1, 'abc'); + + assert.notStrictEqual(model1, model2); + }); + + test('getPullRequestCI retains current head ref models per pull request', () => { + const pr1Model = service.getPullRequestCI('owner', 'repo', 1, 'abc'); + service.getPullRequestCI('owner', 'repo', 2, 'def'); + + assert.strictEqual(service.getPullRequestCI('owner', 'repo', 1, 'abc'), pr1Model); + }); + test('disposing service does not throw', () => { service.getRepository('owner', 'repo'); service.getPullRequest('owner', 'repo', 1); - service.getPullRequestCI('owner', 'repo', 'abc'); + service.getPullRequestReviewThreads('owner', 'repo', 1); + service.getPullRequestCI('owner', 'repo', 1, 'abc'); // Disposing the service should not throw and should clean up models assert.doesNotThrow(() => service.dispose()); diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts index 246df06e73643..8c9000d47f9c1 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts @@ -320,6 +320,7 @@ export class RemoteAgentCustomizationItemProvider extends Disposable implements groupKey: badge.groupKey, extensionId: undefined, pluginUri: undefined, + userInvocable: undefined, actions, }; } @@ -358,7 +359,7 @@ export class RemoteAgentCustomizationItemProvider extends Disposable implements const childGroupKey = isClientSynced ? REMOTE_CLIENT_GROUP : REMOTE_HOST_GROUP; plugins.push({ item: isBundleItem - ? { uri: this.toRemoteUri(sessionCustomization.customization), type: 'plugin', name: '', storage: PromptsStorage.plugin, groupKey: childGroupKey, extensionId: undefined, pluginUri: undefined } + ? { uri: this.toRemoteUri(sessionCustomization.customization), type: 'plugin', name: '', storage: PromptsStorage.plugin, groupKey: childGroupKey, extensionId: undefined, pluginUri: undefined, userInvocable: undefined } : this.toItem(sessionCustomization.customization, sessionCustomization), nonce: sessionCustomization.customization.nonce, status: toStatusString(sessionCustomization.status), @@ -513,6 +514,7 @@ export class RemoteAgentCustomizationItemProvider extends Disposable implements groupKey, extensionId: undefined, pluginUri: undefined, + userInvocable: true }); } return items; diff --git a/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts b/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts index ee724ffedbf93..7bc9365ed1ac4 100644 --- a/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts @@ -21,6 +21,9 @@ import { IAICustomizationItemsModel } from '../../../../workbench/contrib/chat/b import { CUSTOMIZATION_ITEMS } from './customizationsToolbar.contribution.js'; import { Menus } from '../../../browser/menus.js'; import { IAgentPluginService } from '../../../../workbench/contrib/chat/common/plugins/agentPluginService.js'; +import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { AICustomizationManagementEditor } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.js'; +import { AICustomizationManagementEditorInput } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.js'; const $ = DOM.$; @@ -42,6 +45,7 @@ export class AICustomizationShortcutsWidget extends Disposable { @IMcpService private readonly mcpService: IMcpService, @IAgentPluginService private readonly agentPluginService: IAgentPluginService, @IAICustomizationItemsModel private readonly itemsModel: IAICustomizationItemsModel, + @IEditorService private readonly editorService: IEditorService, ) { super(); @@ -82,6 +86,26 @@ export class AICustomizationShortcutsWidget extends Disposable { const headerTotalCount = DOM.append(chevronContainer, $('span.ai-customization-header-total.hidden')); chevron.classList.add(...ThemeIcon.asClassNameArray(isCollapsed ? Codicon.chevronRight : Codicon.chevronDown)); + const headerActions = DOM.append(header, $('.ai-customization-header-actions')); + const openOverviewLabel = localize('openCustomizationsOverview', "Open Customizations Overview"); + const openOverviewButton = this._register(new Button(headerActions, { + ...defaultButtonStyles, + secondary: true, + title: openOverviewLabel, + ariaLabel: openOverviewLabel, + supportIcons: true, + buttonSecondaryBackground: 'transparent', + buttonSecondaryHoverBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryBorder: undefined, + })); + openOverviewButton.element.classList.add('ai-customization-overview-button'); + openOverviewButton.label = `$(${Codicon.home.id})`; + this._register(openOverviewButton.onDidClick(e => { + e?.preventDefault(); + this._openWelcomePage(); + })); + // Toolbar container const toolbarContainer = DOM.append(container, $('.ai-customization-toolbar-content.sidebar-action-list')); @@ -140,6 +164,14 @@ export class AICustomizationShortcutsWidget extends Disposable { this._register(headerButton.onDidClick(() => toggleCollapse())); } + private async _openWelcomePage(): Promise { + const input = AICustomizationManagementEditorInput.getOrCreate(); + const editor = await this.editorService.openEditor(input, { pinned: true }); + if (editor instanceof AICustomizationManagementEditor) { + editor.showWelcomePage(); + } + } + focus(): void { this._headerButton?.element.focus(); } diff --git a/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css b/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css index c7c4284c4f15a..6e9ac0bb73d04 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css +++ b/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css @@ -39,6 +39,7 @@ align-items: center; -webkit-user-select: none; user-select: none; + gap: 4px; } .ai-customization-toolbar .ai-customization-header:not(.collapsed) { @@ -81,6 +82,40 @@ flex: 1; } +.ai-customization-toolbar .ai-customization-header-actions { + flex-shrink: 0; + display: flex; + align-items: center; + margin-right: 4px; +} + +.ai-customization-toolbar .ai-customization-overview-button.monaco-button { + width: 24px; + height: 24px; + padding: 0; + margin: 0; + border: none; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + color: var(--vscode-descriptionForeground); + background: transparent; + opacity: 0.85; +} + +.ai-customization-toolbar .ai-customization-overview-button.monaco-button:hover, +.ai-customization-toolbar .ai-customization-overview-button.monaco-button:focus { + color: var(--vscode-agentsPanel-foreground, var(--vscode-foreground)); + background-color: var(--vscode-toolbar-hoverBackground); + opacity: 1; +} + +.ai-customization-toolbar .ai-customization-overview-button.monaco-button .codicon { + margin: 0; + font-size: 16px; +} + /* Button needs relative positioning for counts overlay */ .ai-customization-toolbar .customization-link-button { position: relative; diff --git a/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts b/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts index 79dbe1b675b7d..11ec677d399b4 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts @@ -20,6 +20,7 @@ import { ComponentFixtureContext, createEditorServices, defineComponentFixture, import { AICustomizationShortcutsWidget } from '../../browser/aiCustomizationShortcutsWidget.js'; import { CUSTOMIZATION_ITEMS, CustomizationLinkViewItem } from '../../browser/customizationsToolbar.contribution.js'; import { Menus } from '../../../../browser/menus.js'; +import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; // Ensure color registrations are loaded import '../../../../common/theme.js'; @@ -158,6 +159,12 @@ function renderWidget(ctx: ComponentFixtureContext, options?: { mcpServerCount?: reg.defineInstance(IAgentPluginService, new class extends mock() { override readonly plugins = observableValue('mockPlugins', []); }()); + reg.defineInstance(IEditorService, new class extends mock() { + override readonly onDidActiveEditorChange = Event.None; + override readonly onDidVisibleEditorsChange = Event.None; + override readonly onDidEditorsChange = Event.None; + override async openEditor() { return undefined; } + }()); }, }); diff --git a/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.test.ts b/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.test.ts new file mode 100644 index 0000000000000..b3b731293303e --- /dev/null +++ b/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.test.ts @@ -0,0 +1,202 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Event } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestCommandService } from '../../../../../editor/test/browser/editorTestServices.js'; +import { IActionViewItemFactory, IActionViewItemService } from '../../../../../platform/actions/browser/actionViewItemService.js'; +import { IMenu, IMenuActionOptions, IMenuService, MenuId } from '../../../../../platform/actions/common/actions.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; +import { MockContextKeyService, MockKeybindingService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IStorageService, InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { NullTelemetryServiceShape } from '../../../../../platform/telemetry/common/telemetryUtils.js'; +import { AICustomizationManagementEditor } from '../../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.js'; +import { AICustomizationManagementEditorInput } from '../../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.js'; +import { IAICustomizationItemsModel, ItemsModelSection } from '../../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.js'; +import { IAICustomizationListItem } from '../../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.js'; +import { IAgentPluginService } from '../../../../../workbench/contrib/chat/common/plugins/agentPluginService.js'; +import { IMcpServer, IMcpService } from '../../../../../workbench/contrib/mcp/common/mcpTypes.js'; +import { IEditorService, PreferredGroup } from '../../../../../workbench/services/editor/common/editorService.js'; +import { IEditorPane, IResourceDiffEditorInput, ITextDiffEditorPane, ITextResourceDiffEditorInput, IUntitledTextResourceEditorInput, IUntypedEditorInput } from '../../../../../workbench/common/editor.js'; +import { EditorInput } from '../../../../../workbench/common/editor/editorInput.js'; +import { IEditorOptions, IResourceEditorInput, ITextResourceEditorInput } from '../../../../../platform/editor/common/editor.js'; +import { AICustomizationShortcutsWidget } from '../../browser/aiCustomizationShortcutsWidget.js'; + +class TestActionViewItemService implements IActionViewItemService { + declare _serviceBrand: undefined; + + readonly onDidChange = Event.None; + + register(_menu: MenuId, _commandId: string | MenuId, _provider: IActionViewItemFactory): { dispose(): void } { + return { dispose: () => { } }; + } + + lookUp(_menu: MenuId, _commandId: string | MenuId): IActionViewItemFactory | undefined { + return undefined; + } +} + +class TestMenuService implements IMenuService { + declare readonly _serviceBrand: undefined; + + createMenu(_id: MenuId): IMenu { + return { + onDidChange: Event.None, + dispose: () => { }, + getActions: () => [], + }; + } + + getMenuActions(_id: MenuId, _contextKeyService: unknown, _options?: IMenuActionOptions) { return []; } + getMenuContexts() { return new Set(); } + resetHiddenStates() { } +} + +type TestWelcomeEditor = AICustomizationManagementEditor & { showWelcomePageCallCount: number }; + +function createTestWelcomeEditor(): TestWelcomeEditor { + const editor = Object.create(AICustomizationManagementEditor.prototype) as TestWelcomeEditor; + editor.showWelcomePageCallCount = 0; + editor.showWelcomePage = () => { + editor.showWelcomePageCallCount++; + }; + return editor; +} + +class TestEditorService extends mock() { + override readonly onDidActiveEditorChange = Event.None; + override readonly onDidVisibleEditorsChange = Event.None; + override readonly onDidEditorsChange = Event.None; + + openEditorCallCount = 0; + lastInput: EditorInput | IUntypedEditorInput | undefined; + readonly editor = createTestWelcomeEditor(); + + override openEditor(editor: IResourceEditorInput, group?: PreferredGroup): Promise; + override openEditor(editor: ITextResourceEditorInput | IUntitledTextResourceEditorInput, group?: PreferredGroup): Promise; + override openEditor(editor: ITextResourceDiffEditorInput | IResourceDiffEditorInput, group?: PreferredGroup): Promise; + override openEditor(editor: IUntypedEditorInput, group?: PreferredGroup): Promise; + override openEditor(editor: EditorInput, options?: IEditorOptions, group?: PreferredGroup): Promise; + override async openEditor(editor: EditorInput | IUntypedEditorInput, _optionsOrGroup?: IEditorOptions | PreferredGroup, _group?: PreferredGroup): Promise { + this.openEditorCallCount++; + this.lastInput = editor; + return this.editor; + } +} + +function createMockItemsModel(): IAICustomizationItemsModel { + const emptyItems = observableValue('emptyCustomizationItems', []); + const zeroCount = observableValue('emptyCustomizationCount', 0); + + return new class extends mock() { + override getItems(_section: ItemsModelSection): IObservable { + return emptyItems; + } + + override getCount(_section: ItemsModelSection): IObservable { + return zeroCount; + } + }(); +} + +function createWidget(disposables: DisposableStore, storageService = disposables.add(new InMemoryStorageService())): { container: HTMLElement; editorService: TestEditorService; storageService: InMemoryStorageService } { + const container = document.createElement('div'); + document.body.appendChild(container); + disposables.add({ dispose: () => container.remove() }); + + const editorService = new TestEditorService(); + const instantiationService = createInstantiationService(disposables, storageService, editorService); + + disposables.add(instantiationService.createInstance(AICustomizationShortcutsWidget, container, undefined)); + return { container, editorService, storageService }; +} + +function createInstantiationService(disposables: DisposableStore, storageService: IStorageService, editorService: IEditorService): TestInstantiationService { + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.set(IMenuService, new TestMenuService()); + instantiationService.set(IActionViewItemService, new TestActionViewItemService()); + instantiationService.set(IContextKeyService, new MockContextKeyService()); + instantiationService.set(IContextMenuService, { + _serviceBrand: undefined, + onDidShowContextMenu: Event.None, + onDidHideContextMenu: Event.None, + showContextMenu: () => { }, + }); + instantiationService.set(IKeybindingService, new MockKeybindingService()); + instantiationService.set(ICommandService, new TestCommandService(instantiationService)); + instantiationService.set(ITelemetryService, new NullTelemetryServiceShape()); + instantiationService.set(IStorageService, storageService); + instantiationService.set(IEditorService, editorService); + instantiationService.set(IAICustomizationItemsModel, createMockItemsModel()); + instantiationService.set(IMcpService, new class extends mock() { + override readonly servers = observableValue('emptyMcpServers', []); + }()); + instantiationService.set(IAgentPluginService, new class extends mock() { + override readonly plugins = observableValue('emptyPlugins', []); + }()); + return instantiationService; +} + +suite('AICustomizationShortcutsWidget', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('overview button opens the welcome page without collapsing the section', async () => { + const testDisposables = disposables.add(new DisposableStore()); + const { container, editorService, storageService } = createWidget(testDisposables); + const toolbar = container.querySelector('.ai-customization-toolbar'); + const overviewButton = container.querySelector('.ai-customization-overview-button'); + + assert.ok(toolbar); + assert.ok(overviewButton); + assert.strictEqual(toolbar.classList.contains('collapsed'), false); + assert.strictEqual(storageService.getBoolean('agentSessions.customizationsCollapsed', StorageScope.PROFILE, false), false); + + overviewButton.click(); + await new Promise(resolve => setTimeout(resolve, 0)); + if (editorService.lastInput instanceof EditorInput) { + testDisposables.add(editorService.lastInput); + } + + assert.deepStrictEqual({ + openEditorCallCount: editorService.openEditorCallCount, + openedInputIsManagementEditor: editorService.lastInput instanceof AICustomizationManagementEditorInput, + showWelcomePageCallCount: editorService.editor.showWelcomePageCallCount, + collapsed: toolbar.classList.contains('collapsed'), + storedCollapsed: storageService.getBoolean('agentSessions.customizationsCollapsed', StorageScope.PROFILE, false), + ariaLabel: overviewButton.getAttribute('aria-label'), + }, { + openEditorCallCount: 1, + openedInputIsManagementEditor: true, + showWelcomePageCallCount: 1, + collapsed: false, + storedCollapsed: false, + ariaLabel: 'Open Customizations Overview', + }); + }); + + test('overview button remains available when the section is collapsed', () => { + const testDisposables = disposables.add(new DisposableStore()); + const storageService = testDisposables.add(new InMemoryStorageService()); + storageService.store('agentSessions.customizationsCollapsed', true, StorageScope.PROFILE, StorageTarget.USER); + const { container } = createWidget(testDisposables, storageService); + + assert.deepStrictEqual({ + collapsed: container.querySelector('.ai-customization-toolbar')?.classList.contains('collapsed'), + overviewButtonVisible: !!container.querySelector('.ai-customization-overview-button'), + }, { + collapsed: true, + overviewButtonVisible: true, + }); + }); +}); diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index 10b6d60ecf471..16f6103d3fdf1 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -271,12 +271,15 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA return { uri: hookFile.uri, sessionTypes: hookFile.sessionTypes, + source: this._toChatResourceSource(hookFile.storage), + extensionId: hookFile.extension?.identifier.value, + pluginUri: hookFile.pluginUri, }; } private _toPluginDto(plugin: IAgentPlugin): IPluginDto { return { - uri: plugin.uri + uri: plugin.uri, }; } @@ -753,8 +756,9 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA groupKey: item.groupKey, badge: item.badge, badgeTooltip: item.badgeTooltip, - extensionId: undefined, - pluginUri: undefined + extensionId: item.extensionId, + pluginUri: item.pluginUri ? URI.revive(item.pluginUri) : undefined, + userInvocable: item.userInvocable, })); }, }; diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 79ea40ff4d957..2139682d8d423 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1719,6 +1719,9 @@ export interface ISlashCommandDto extends IChatResourceDto { export interface IHookDto { readonly uri: UriComponents; readonly sessionTypes?: readonly string[]; + readonly source: IChatResourceSourceDto; + readonly extensionId?: string; + readonly pluginUri?: UriComponents; } export interface IPluginDto { @@ -1738,8 +1741,10 @@ export interface IChatSessionCustomizationItemDto { readonly description?: string; readonly groupKey?: string; readonly badge?: string; - + readonly extensionId?: string; + readonly pluginUri?: UriComponents; readonly badgeTooltip?: string; + readonly userInvocable?: boolean; } export interface IChatParticipantMetadata { participant: string; diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 08e75067940b4..22976e21e1d6f 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -589,7 +589,13 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS } private toHook(dto: IHookDto): vscode.ChatHook { - return Object.freeze({ uri: URI.revive(dto.uri), sessionTypes: dto.sessionTypes }); + return Object.freeze({ + uri: URI.revive(dto.uri), + sessionTypes: dto.sessionTypes, + source: dto.source, + extensionId: dto.extensionId, + pluginUri: dto.pluginUri ? URI.revive(dto.pluginUri) : undefined, + }); } private toPlugin(dto: IPluginDto): vscode.ChatPlugin { @@ -830,7 +836,10 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS description: item.description, groupKey: item.groupKey, badge: item.badge, - badgeTooltip: item.badgeTooltip + badgeTooltip: item.badgeTooltip, + extensionId: item.extensionId, + pluginUri: item.pluginUri, + userInvocable: item.userInvocable, } satisfies IChatSessionCustomizationItemDto)); } catch (err) { return undefined; diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts index 50412147a2a71..e010f27d84c76 100644 --- a/src/vs/workbench/contrib/browserView/common/browserView.ts +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -234,7 +234,7 @@ export interface IBrowserViewModel extends IDisposable { stopFindInPage(keepSelection?: boolean): Promise; getSelectedText(): Promise; clearStorage(): Promise; - setSharedWithAgent(shared: boolean): Promise; + setSharedWithAgent(shared: boolean): Promise; trustCertificate(host: string, fingerprint: string): Promise; untrustCertificate(host: string, fingerprint: string): Promise; zoomIn(): Promise; @@ -576,7 +576,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { private static readonly SHARE_DONT_ASK_KEY = 'browserView.shareWithAgent.dontAskAgain'; - async setSharedWithAgent(shared: boolean): Promise { + async setSharedWithAgent(shared: boolean): Promise { if (shared) { // Block sharing when the current page URL is denied by network policy. if (this._url) { @@ -587,7 +587,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { localize('browserView.shareBlocked.title', "Cannot Share with Agent"), this.agentNetworkFilterService.formatError(uri), ); - return; + return false; } } catch { } } @@ -623,7 +623,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { ); if (!result.confirmed) { - return; + return false; } } else { this.telemetryService.publicLog2( @@ -641,6 +641,8 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { await this.playwrightService.stopTrackingPage(this.id); this._setSharedWithAgent(false); } + + return true; } private _setSharedWithAgent(isShared: boolean): void { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts index 641cd8bb0dc37..a3f4546727770 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts @@ -12,7 +12,7 @@ import { IAgentNetworkFilterService } from '../../../../../platform/networkFilte import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IToolResult } from '../../../chat/common/tools/languageModelToolsService.js'; import { BrowserEditorInput } from '../../common/browserEditorInput.js'; -import { IBrowserViewWorkbenchService } from '../../common/browserView.js'; +import { BrowserViewSharingState, IBrowserViewWorkbenchService } from '../../common/browserView.js'; // eslint-disable-next-line local/code-import-patterns import type { Page } from 'playwright-core'; @@ -51,7 +51,7 @@ export function formatBrowserEditorList(editorService: IEditorService, editors: const title = blocked ? localize('browser.blockedByPolicy', "Blocked by network domain policy") : (editor.title || 'Untitled'); const displayUrl = blocked ? '' : ` (${url})`; - const hint = editor === activeEditor ? ' (active)' : visibleEditors.has(editor) ? ' (visible)' : ''; + const hint = editor === activeEditor ? ' (active)' : visibleEditors.has(editor) ? ' (visible)' : ' (not visible)'; const id = options?.excludeIds ? '' : `[${editor.id}] `; // By default, use numbers only if we're excluding IDs, so models don't get confused about which ID to use. @@ -142,49 +142,40 @@ export function errorResult(message: string): IToolResult { } /** - * Checks whether a browser editor with the same host (hostname + port) already - * exists. When {@link playwrightService} is provided, only pages tracked by Playwright - * (i.e. shared with the agent) are considered. + * Checks whether a browser editor with the same host (hostname + port) already exists. * * @returns All matching {@link BrowserEditorInput}s. */ -async function findExistingPagesByHost( +export function findExistingPagesByHost( browserViewService: IBrowserViewWorkbenchService, - playwrightService: IPlaywrightService | undefined, url: string, -): Promise { + options?: { + includeBlank?: boolean; + sharingState?: BrowserViewSharingState; + } +): BrowserEditorInput[] { const parsed = URL.parse(url); if (!parsed || (parsed.protocol !== 'file:' && !parsed.host)) { return []; } - const trackedIds = playwrightService - ? new Set(await playwrightService.getTrackedPages()) - : undefined; - const results: BrowserEditorInput[] = []; for (const editor of browserViewService.getKnownBrowserViews().values()) { if (!(editor instanceof BrowserEditorInput)) { continue; } - if (trackedIds && !trackedIds.has(editor.id)) { + if (options?.sharingState && editor.model?.sharingState !== options.sharingState) { continue; } const editorUrl = URL.parse(editor.url || ''); if ( - !editor.url || + options?.includeBlank && (!editor.url || editor.url === 'about:blank') || editorUrl?.host === parsed.host || - (parsed.protocol === 'file:' && editorUrl?.protocol === 'file:') - ) { - results.push(editor); - } - // Check for subdomain matches - if ( - editorUrl?.host && parsed.host && - ( + (parsed.protocol === 'file:' && editorUrl?.protocol === 'file:') || + (editorUrl?.host && parsed.host && ( editorUrl.host.endsWith('.' + parsed.host) || parsed.host.endsWith('.' + editorUrl.host) - ) + )) ) { results.push(editor); } @@ -198,12 +189,9 @@ async function findExistingPagesByHost( */ export async function getExistingPagesResult( editorService: IEditorService, - browserViewService: IBrowserViewWorkbenchService, - playwrightService: IPlaywrightService | undefined, - url: string, + existing: BrowserEditorInput[], formatOptions?: FormatBrowserEditorLinesOptions ): Promise { - const existing = await findExistingPagesByHost(browserViewService, playwrightService, url); if (existing.length === 0) { return undefined; } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts index b1830ed66f11c..3e53460e4b0be 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts @@ -104,6 +104,8 @@ class BrowserChatAgentToolsContribution extends Disposable implements IWorkbench this._syncModelListeners(); this._updateBrowserContext(); })); + this._toolsStore.add(this.editorService.onDidActiveEditorChange(() => this._updateBrowserContext())); + this._toolsStore.add(this.editorService.onDidVisibleEditorsChange(() => this._updateBrowserContext())); this._toolsStore.add(this.agentNetworkFilterService.onDidChange(() => this._updateBrowserContext())); this._updateBrowserContext(); @@ -154,6 +156,7 @@ class BrowserChatAgentToolsContribution extends Disposable implements IWorkbench value += '\n\n'; } value += `${unsharedCount} ${unsharedCount === 1 ? 'page is' : 'pages are'} open but not shared.`; + value += `\nUse the 'open_browser_page' tool to open a new page or to help the user share an existing page.`; } this.chatContextService.updateWorkspaceContextItems(BrowserChatAgentToolsContribution.CONTEXT_ID, [{ diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts index 89b5d2159b023..747dbe4a85def 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts @@ -3,17 +3,27 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { raceCancellation } from '../../../../../base/common/async.js'; import type { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; +import { CancellationError } from '../../../../../base/common/errors.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { hasKey } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IAgentNetworkFilterService } from '../../../../../platform/networkFilter/common/networkFilterService.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IChatQuestion, IChatQuestionAnswers, IChatService, IChatSingleSelectAnswer } from '../../../chat/common/chatService/chatService.js'; +import { ChatConfiguration, ChatPermissionLevel } from '../../../chat/common/constants.js'; +import { ChatQuestionCarouselData } from '../../../chat/common/model/chatProgressTypes/chatQuestionCarouselData.js'; +import { IChatRequestModel } from '../../../chat/common/model/chatModel.js'; import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; -import { IAgentNetworkFilterService } from '../../../../../platform/networkFilter/common/networkFilterService.js'; -import { createBrowserPageLink, getExistingPagesResult } from './browserToolHelpers.js'; -import { IBrowserViewWorkbenchService } from '../../common/browserView.js'; +import { BrowserViewSharingState, IBrowserViewWorkbenchService } from '../../common/browserView.js'; +import { BrowserEditorInput } from '../../common/browserEditorInput.js'; +import { createBrowserPageLink, findExistingPagesByHost, getExistingPagesResult } from './browserToolHelpers.js'; export const OpenPageToolId = 'open_browser_page'; @@ -22,7 +32,11 @@ export const OpenBrowserToolData: IToolData = { toolReferenceName: 'openBrowserPage', displayName: localize('openBrowserTool.displayName', 'Open Browser Page'), userDescription: localize('openBrowserTool.userDescription', 'Open a URL in the integrated browser'), - modelDescription: 'Open a new browser page in the integrated browser at the given URL. Returns a page ID that must be used with other browser tools to interact with the page. Prefer to reuse existing pages whenever possible and only call this tool if a new page is necessary.', + modelDescription: `Open a new browser page in the integrated browser at the given URL. +May prompt the user to share a page if there is a similar one already open, unless "forceNew" is true. +Returns a page ID that must be used with other browser tools to interact with the page, as well as an accessibility snapshot of the page. + +Important: Prefer to reuse existing pages whenever possible and only call this tool if you do not already have access to a tab you can reuse.`, icon: Codicon.openInProduct, source: ToolDataSource.Internal, inputSchema: { @@ -37,28 +51,36 @@ export const OpenBrowserToolData: IToolData = { description: 'Whether to force opening a new page even if a page with the same host already exists. Default is false.' } }, - required: ['url'], + $comment: 'If you omit "url", the user will be prompted to share an existing page instead. Use this if there are unshared pages that the user may be interested in sharing with you.' }, }; export interface IOpenBrowserToolParams { - url: string; + url?: string; forceNew?: boolean; } +const DECLINE_OPTION_ID = '__decline__'; + export class OpenBrowserTool implements IToolImpl { constructor( @IPlaywrightService private readonly playwrightService: IPlaywrightService, @IEditorService private readonly editorService: IEditorService, @IBrowserViewWorkbenchService private readonly browserViewService: IBrowserViewWorkbenchService, @IAgentNetworkFilterService private readonly agentNetworkFilterService: IAgentNetworkFilterService, + @IChatService private readonly chatService: IChatService, + @IConfigurationService private readonly configService: IConfigurationService, + @ILogService private readonly logService: ILogService, ) { } async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { const params = context.parameters as IOpenBrowserToolParams; if (!params.url) { - throw new Error('The "url" parameter is required.'); + return { + invocationMessage: localize('browser.open.prompt.invocation', "Prompting user to share a browser tab"), + pastTenseMessage: localize('browser.open.prompt.past', "Prompted user to share a browser tab"), + }; } const parsed = URL.parse(params.url); @@ -66,6 +88,8 @@ export class OpenBrowserTool implements IToolImpl { throw new Error('You must provide a complete, valid URL.'); } + params.url = parsed.href; // Ensure URL is in a normalized format + const uri = URI.parse(params.url); if (!this.agentNetworkFilterService.isUriAllowed(uri)) { throw new Error(this.agentNetworkFilterService.formatError(uri)); @@ -82,27 +106,212 @@ export class OpenBrowserTool implements IToolImpl { }; } - async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, _token: CancellationToken): Promise { + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, token: CancellationToken): Promise { const params = invocation.parameters as IOpenBrowserToolParams; + // If no URL is specified, prompt the user for a page to share. + if (!params.url) { + const allPages = [...this.browserViewService.getKnownBrowserViews().values()]; + if (allPages.length === 0) { + return { content: [{ kind: 'text', value: 'No browser pages are currently open.' }] }; + } + + const shareResult = await this._promptForUnsharedPages(invocation, allPages, params, token); + if (shareResult) { + return shareResult; + } else { + return { content: [{ kind: 'text', value: 'The user opted not to share an existing page.' }] }; + } + } + if (!params.forceNew) { - const existingResult = await getExistingPagesResult(this.editorService, this.browserViewService, this.playwrightService, params.url, { agentNetworkFilterService: this.agentNetworkFilterService }); - if (existingResult) { - return existingResult; + // If there are already-shared pages, tell the model to reuse them + const shared = findExistingPagesByHost(this.browserViewService, params.url, { includeBlank: true, sharingState: BrowserViewSharingState.Shared }); + const alreadyShared = await getExistingPagesResult(this.editorService, shared, { agentNetworkFilterService: this.agentNetworkFilterService }); + if (alreadyShared) { + return alreadyShared; + } + + // If there are unshared (but shareable) pages on the same host, prompt user to share one + const unshared = findExistingPagesByHost(this.browserViewService, params.url, { includeBlank: false, sharingState: BrowserViewSharingState.NotShared }); + if (unshared.length > 0) { + const shareResult = await this._promptForUnsharedPages(invocation, unshared, params, token); + if (shareResult) { + return shareResult; + } + } + } + + return this._openNewPage(params.url); + } + + /** + * Shows a carousel prompting the user to share one of the given unshared + * browser pages instead of opening a new page. Returns `undefined` if the + * prompt should be skipped or the user chose to open a new page. + */ + private async _promptForUnsharedPages(invocation: IToolInvocation, candidateEditors: BrowserEditorInput[], params: IOpenBrowserToolParams, token: CancellationToken): Promise { + + const chatSessionResource = invocation.context?.sessionResource; + const chatRequestId = invocation.chatRequestId; + const request = this._getRequest(chatSessionResource, chatRequestId); + + if (!request) { + return undefined; // No chat context — skip prompt, proceed to open + } + + // In autopilot/auto-reply, don't block — just open the new page + if (request.modeInfo?.permissionLevel === ChatPermissionLevel.Autopilot || this.configService.getValue(ChatConfiguration.AutoReply)) { + return undefined; + } + + const carousel = this._buildShareCarousel(candidateEditors, params.url, invocation.chatStreamToolCallId ?? invocation.callId); + this.chatService.appendProgress(request, carousel); + + const externalAnswerListener = this.chatService.onDidReceiveQuestionCarouselAnswer(event => { + if (event.resolveId !== carousel.resolveId || carousel.isUsed) { + return; } + carousel.dismiss(event.answers); + }); + + let answerResult: { answers: IChatQuestionAnswers | undefined } | undefined; + try { + answerResult = await raceCancellation(carousel.completion.p, token); + } catch (error) { + if (error instanceof CancellationError) { + carousel.dismiss(undefined); + } + throw error; + } finally { + externalAnswerListener.dispose(); + } + + if (!answerResult || token.isCancellationRequested) { + carousel.dismiss(undefined); + throw new CancellationError(); + } + + // Extract the selected option + const selectedOptionId = this._extractSelectedOption(answerResult.answers); + + // User skipped/cancelled or chose "Open new page" — fall through to open + if (!selectedOptionId || selectedOptionId === DECLINE_OPTION_ID) { + return undefined; } - const { pageId, summary } = await this.playwrightService.openPage(params.url); + // User selected an existing tab + const editor = this.browserViewService.getKnownBrowserViews().get(selectedOptionId); + if (!editor) { + this.logService.warn(`[OpenBrowserTool] Selected option '${selectedOptionId}' not found.`); + return undefined; + } + return this._shareExistingPage(editor); + } + + private _buildShareCarousel(editors: BrowserEditorInput[], url: string | undefined, resolveId: string): ChatQuestionCarouselData { + const options: IChatQuestion['options'] = []; + + for (const editor of editors) { + const editorTitle = (editor.title || editor.getName()).replaceAll(' - ', '\u00A0-\u00A0'); // nbsp around hyphens to prevent formatting in the carousel + const editorUrl = editor.url || 'about:blank'; + const truncatedUrl = editorUrl.length > 40 ? editorUrl.substring(0, 40) + '\u2026' : editorUrl; + options.push({ + id: editor.id, + label: localize( + { key: 'browser.open.shareExistingOption', comment: ['{Locked=" - "}', '{0} is the editor title', '{1} is the truncated URL'] }, + 'Yes, share "{0}" - {1}', + editorTitle, + truncatedUrl, + ), + value: editor.id, + }); + } + + // Default option: decline sharing + options.push({ + id: DECLINE_OPTION_ID, + label: url + ? localize('browser.open.newPageOption', "No, open a new page at {0}", url) + : localize({ key: 'browser.open.noPagesOption', comment: ['{Locked=" - "}'] }, "No - Do not share any tabs with the agent"), + value: DECLINE_OPTION_ID, + }); + + const question: IChatQuestion = { + id: `${resolveId}:0`, + type: 'singleSelect', + title: localize('browser.open.shareQuestion.title', "Share Browser Tab"), + message: localize('browser.open.shareQuestion.message', "Share an existing browser tab?"), + options, + defaultValue: DECLINE_OPTION_ID, + allowFreeformInput: false, + }; + + return new ChatQuestionCarouselData([question], true, resolveId); + } + + private _extractSelectedOption(answers: IChatQuestionAnswers | undefined): string | undefined { + if (!answers) { + return undefined; + } + + for (const answer of Object.values(answers)) { + if (typeof answer === 'string') { + return answer; + } + if (typeof answer === 'object' && answer !== null && hasKey(answer, { selectedValue: true })) { + return (answer as IChatSingleSelectAnswer).selectedValue; + } + } + + return undefined; + } + + private async _openNewPage(url: string): Promise { + const { pageId, summary } = await this.playwrightService.openPage(url); + return this._pageResult(pageId, summary, localize('browser.open.result', "Opened {0}", createBrowserPageLink(pageId))); + } + + private async _shareExistingPage(editor: BrowserEditorInput): Promise { + const model = await editor.resolve(); + if (model.sharingState !== BrowserViewSharingState.Shared) { + if (!(await model.setSharedWithAgent(true))) { + return { content: [{ kind: 'text', value: 'The user declined to share the page.' }] }; + } + } + + const summary = await this.playwrightService.getSummary(editor.id); + return this._pageResult(editor.id, summary, localize('browser.open.sharedResult', "User shared {0}", createBrowserPageLink(editor.id))); + } + + private _pageResult(pageId: string, summary: string, resultMessage: string): IToolResult { return { - content: [{ - kind: 'text', - value: `Page ID: ${pageId}\n\nSummary:\n`, - }, { - kind: 'text', - value: summary, - }], - toolResultMessage: new MarkdownString(localize('browser.open.result', "Opened {0}", createBrowserPageLink(pageId))) + content: [ + { kind: 'text', value: `Page ID: ${pageId}\n\nSummary:\n` }, + { kind: 'text', value: summary }, + ], + toolResultMessage: new MarkdownString(resultMessage), }; } + + private _getRequest(chatSessionResource: URI | undefined, chatRequestId: string | undefined): IChatRequestModel | undefined { + if (!chatSessionResource) { + return undefined; + } + + const model = this.chatService.getSession(chatSessionResource); + if (!model) { + return undefined; + } + + if (chatRequestId) { + const request = model.getRequests().find(r => r.id === chatRequestId); + if (request) { + return request; + } + } + + return model.getRequests().at(-1); + } } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserToolNonAgentic.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserToolNonAgentic.ts index c280c2955482a..bb33f871c5534 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserToolNonAgentic.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserToolNonAgentic.ts @@ -13,12 +13,17 @@ import { IEditorService } from '../../../../services/editor/common/editorService import { type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; import { IOpenBrowserToolParams, OpenBrowserToolData } from './openBrowserTool.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; -import { createBrowserPageLink, getExistingPagesResult } from './browserToolHelpers.js'; +import { createBrowserPageLink, findExistingPagesByHost, getExistingPagesResult } from './browserToolHelpers.js'; import { IBrowserViewWorkbenchService } from '../../common/browserView.js'; export const OpenBrowserToolNonAgenticData: IToolData = { ...OpenBrowserToolData, modelDescription: 'Open a new browser page in the integrated browser at the given URL.', + inputSchema: { + ...OpenBrowserToolData.inputSchema, + required: ['url'], + $comment: undefined + } }; export class OpenBrowserToolNonAgentic implements IToolImpl { @@ -54,7 +59,8 @@ export class OpenBrowserToolNonAgentic implements IToolImpl { const params = invocation.parameters as IOpenBrowserToolParams; if (!params.forceNew) { - const existingResult = await getExistingPagesResult(this.editorService, this.browserViewService, undefined, params.url, { excludeIds: true }); + const existingPages = findExistingPagesByHost(this.browserViewService, params.url!); + const existingResult = await getExistingPagesResult(this.editorService, existingPages, { excludeIds: true }); if (existingResult) { return existingResult; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts index 143a2b41237c5..a3da14439c250 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts @@ -7,16 +7,17 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; -import { basename, dirname, isEqualOrParent } from '../../../../../../base/common/resources.js'; +import { basename, isEqualOrParent } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { type URI as ProtocolURI } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { type CustomizationRef } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; -import { IPromptPath, IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; +import { IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; import { type ICustomizationSyncProvider, type ICustomizationItem, type ICustomizationItemProvider } from '../../../common/customizationHarnessService.js'; import { IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; import { getFriendlyName } from '../../aiCustomization/aiCustomizationItemSource.js'; import type { SyncedCustomizationBundler } from './syncedCustomizationBundler.js'; +import { getSkillFolderName } from '../../../common/promptSyntax/config/promptFileLocations.js'; /** * Prompt types that participate in auto-sync to an agent host harness. @@ -48,6 +49,8 @@ export interface ILocalCustomizationFile { readonly type: PromptsType; readonly storage: PromptsStorage; readonly disabled: boolean; + readonly pluginUri?: URI; + readonly extensionId?: string; } /** @@ -70,11 +73,13 @@ export async function enumerateLocalCustomizationsForHarness( ); for (let i = 0; i < lists.length; i++) { const storage = SYNCABLE_STORAGE_SOURCES[i]; - for (const file of lists[i] as readonly IPromptPath[]) { + for (const file of lists[i]) { result.push({ uri: file.uri, type, storage, + pluginUri: file.pluginUri, + extensionId: file.extension?.identifier.value, disabled: syncProvider.isDisabled(file.uri), }); } @@ -122,17 +127,19 @@ export class LocalAgentHostCustomizationItemProvider extends Disposable implemen // folder, so the filename is not a useful display name. Look up the // parsed skill metadata (name + description from frontmatter) and // fall back to the parent folder name when a skill failed to parse. - const skillByUri = new ResourceMap<{ name: string; description: string | undefined }>(); + const skillByUri = new ResourceMap<{ name: string; description: string | undefined; userInvocable?: boolean }>(); for (const skill of skills ?? []) { - skillByUri.set(skill.uri, { name: skill.name, description: skill.description }); + skillByUri.set(skill.uri, { name: skill.name, description: skill.description, userInvocable: skill.userInvocable }); } return enumerated.map(file => { let name: string; let description: string | undefined; + let userInvocable: boolean | undefined; if (file.type === PromptsType.skill) { const parsed = skillByUri.get(file.uri); - name = parsed?.name ?? basename(dirname(file.uri)); + name = parsed?.name ?? getSkillFolderName(file.uri); description = parsed?.description; + userInvocable = parsed?.userInvocable; } else { name = getFriendlyName(basename(file.uri)); } @@ -143,8 +150,9 @@ export class LocalAgentHostCustomizationItemProvider extends Disposable implemen description, storage: file.storage, enabled: !file.disabled, - extensionId: undefined, - pluginUri: undefined, + extensionId: file.extensionId, + pluginUri: file.pluginUri, + userInvocable }; }); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index d0bc340024683..57d9e13275e5e 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Throttler } from '../../../../../../base/common/async.js'; import { encodeBase64 } from '../../../../../../base/common/buffer.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { BugIndicatingError, isCancellationError } from '../../../../../../base/common/errors.js'; @@ -11,18 +10,18 @@ import { Emitter, Event } from '../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { Disposable, DisposableResourceMap, DisposableStore, IReference, MutableDisposable, toDisposable, type IDisposable } from '../../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; -import { autorun, derived, IObservable, observableValue, transaction } from '../../../../../../base/common/observable.js'; +import { autorun, autorunPerKeyedItem, derived, IObservable, observableValue, transaction } from '../../../../../../base/common/observable.js'; import { extUriBiasedIgnorePathCase, isEqual } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; import { AgentProvider, AgentSession, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; -import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; +import { IAgentSubscription, observableFromSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { SessionTruncatedAction } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; import { ConfirmationOptionKind, CustomizationRef, TerminalClaimKind, ToolResultContentType, type ConfirmationOption, type ProtectedResourceMetadata, type ToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, SessionTurnStartedAction, type ClientSessionAction, type SessionAction, type SessionInputCompletedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import { AttachmentType, buildSubagentSessionUri, getToolFileEdits, getToolSubagentContent, PendingMessageKind, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, type ICompletedToolCall, type MessageAttachment, type ModelSelection, type ResponsePart, type RootState, type SessionInputAnswer, type SessionInputRequest, type SessionState, type ToolCallState, type Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { AttachmentType, buildSubagentSessionUri, getToolFileEdits, getToolSubagentContent, PendingMessageKind, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, type ICompletedToolCall, type MarkdownResponsePart, type MessageAttachment, type ModelSelection, type ReasoningResponsePart, type RootState, type SessionInputAnswer, type SessionInputRequest, type SessionState, type ToolCallResponsePart, type ToolCallState, type Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; @@ -55,55 +54,40 @@ import { activeTurnToProgress, completedToolCallToEditParts, completedToolCallTo // ============================================================================= /** - * Shared context for processing turn state changes. Threaded through - * {@link AgentHostSessionHandler._processSessionState} and - * {@link AgentHostSessionHandler._updateToolCallState}. + * Options threaded into {@link AgentHostSessionHandler._observeTurn}. The + * same observation pipeline is used for live (`_handleTurn`), reconnected + * (snapshot from `provideChatSessionContent`), and server-initiated turns + * (`_watchForServerInitiatedTurns`). The differences are captured here: + * + * - {@link sink} routes emitted progress to either the agent invoke + * callback (live) or `chatSession.appendProgress` (reconnect / + * server-initiated). + * - {@link adoptInvocations} carries `ChatToolInvocation` instances that + * `activeTurnToProgress` already produced so per-tool setup adopts them + * rather than recreating UI handles. + * - {@link seedEmittedLengths} prevents the always-on graph from re-emitting + * markdown / reasoning prefixes already covered by the snapshot. + * - {@link onTurnEnded} fires once when the turn reaches a terminal state. */ -interface ITurnProcessingContext { - readonly turnId: string; +interface IObserveTurnOptions { readonly backendSession: URI; - /** The UI session resource (agent-host-copilot:/...) for tool invocation context. */ readonly sessionResource: URI; - readonly activeToolInvocations: Map; - readonly lastEmittedLengths: Map; - readonly progress: (parts: IChatProgress[]) => void; + readonly turnId: string; + readonly sink: (parts: IChatProgress[]) => void; readonly cancellationToken: CancellationToken; - /** Called when a completed tool produces file edits. */ + readonly adoptInvocations?: ReadonlyMap; + readonly seedEmittedLengths?: ReadonlyMap; + readonly onTurnEnded?: (lastTurn: Turn | undefined) => void; readonly onFileEdits?: (tc: ToolCallState, fileEdits: IToolCallFileEdit[]) => void; -} - -/** - * Per-tool bookkeeping for a client-provided tool invocation managed by - * {@link AgentHostSessionHandler}. The invocation lifecycle (UI, confirmation, - * execution) is driven by {@link ILanguageModelToolsService}; this entry - * tracks the cancellation source, whether `invokeTool` has been called, and - * the eventual result/error. When the tool call is cancelled externally via - * session state, the entry's CTS is fired and `cts.token.isCancellationRequested` - * is the signal that no protocol action should be dispatched (the server - * already knows). - */ -interface IClientToolCallEntry { - readonly invocation: ChatToolInvocation; - readonly disposables: DisposableStore; - readonly cts: CancellationTokenSource; - /** The tool name, used when synthesizing a protocol result. */ - readonly toolName: string; - /** `true` once {@link ILanguageModelToolsService.invokeTool} has been called. */ - invoked: boolean; /** - * `true` once we have dispatched {@link ActionType.SessionToolCallConfirmed} - * with `approved: true`. Used by the settle handler to decide whether to - * also dispatch {@link ActionType.SessionToolCallComplete}: pre-execution - * denials produce `approved: false` only (the server transitions the call - * to `Cancelled` on its own) and must not be followed by a `Complete`. + * When set, this turn is being observed as part of a subagent session. + * Tool calls emitted into {@link sink} are tagged with this id so the + * renderer groups them under the parent subagent widget. Markdown, + * reasoning, and input requests are not forwarded (the subagent's own + * session view renders those); nested subagent observation is also + * suppressed to preserve legacy behavior. */ - approvedDispatched: boolean; -} - -interface IActiveInputRequestEntry { - readonly carousel: ChatQuestionCarouselData; - protocolAnswers: Record | undefined; - completedFromState: boolean; + readonly subAgentInvocationId?: string; } /** @@ -376,14 +360,6 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC /** Observable of client-provided tools filtered by the allowlist and `when` clauses. */ private readonly _clientToolsObs: IObservable; - /** - * Per-tool state for client-provided tool invocations that are managed - * locally. The `invocation` is created eagerly (in streaming state) so - * the UI has a handle, then {@link ILanguageModelToolsService.invokeTool} - * is called once parameters are available. An autorun on the - * invocation's state dispatches the corresponding protocol actions. - */ - private readonly _clientToolCalls = new Map(); constructor( config: IAgentHostSessionHandlerConfig, @@ -677,11 +653,6 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC await this._handleTurn(resolvedSession, request, progress, cancellationToken); - const activeSession = this._activeSessions.get(request.sessionResource); - if (activeSession) { - activeSession.isCompleteObs.set(true, undefined); - } - return {}; } @@ -893,73 +864,16 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC chatSession: AgentHostChatSession, turnDisposables: DisposableStore, ): void { - const sessionStr = backendSession.toString(); - const activeToolInvocations = new Map(); - const lastEmittedLengths = new Map(); - const activeInputRequests = new Map(); - const observedSubagentToolIds = new Set(); - const throttler = new Throttler(); - turnDisposables.add(throttler); - - const progress = (parts: IChatProgress[]) => { - const current = chatSession.progressObs.get(); - chatSession.progressObs.set([...current, ...parts], undefined); - }; - - let finished = false; - const finish = () => throttler.queue(async () => { - if (finished) { - return; - } - finished = true; - for (const [, invocation] of activeToolInvocations) { - if (!IChatToolInvocation.isComplete(invocation)) { - invocation.didExecuteTool(undefined); - } - } - activeToolInvocations.clear(); - chatSession.isCompleteObs.set(true, undefined); - }); - - const ctx: ITurnProcessingContext = { - turnId, + const cts = new CancellationTokenSource(); + turnDisposables.add(toDisposable(() => cts.dispose(true))); + turnDisposables.add(this._observeTurn({ backendSession, sessionResource: chatSession.sessionResource, - activeToolInvocations, - lastEmittedLengths, - progress, - cancellationToken: CancellationToken.None, - }; - - const processState = (sessionState: SessionState) => { - if (finished) { - return; - } - const isActive = this._processSessionState(sessionState, ctx); - this._syncInputRequests(activeInputRequests, sessionState.inputRequests, backendSession, chatSession.sessionResource, CancellationToken.None, progress); - - // Observe subagent sessions for subagent tool calls - this._observeSubagentToolCalls(sessionState, turnId, activeToolInvocations, observedSubagentToolIds, backendSession, progress, turnDisposables); - - if (!isActive && !finished) { - finish(); - } - }; - - const trackSub = this._ensureSessionSubscription(sessionStr); - turnDisposables.add(trackSub.onWillApplyAction(envelope => this._applyCompletedInputRequest(activeInputRequests, envelope.action as SessionAction))); - turnDisposables.add(trackSub.onDidChange(state => { - throttler.queue(async () => processState(state)); + turnId, + sink: parts => chatSession.appendProgress(parts), + cancellationToken: cts.token, + onTurnEnded: () => chatSession.isCompleteObs.set(true, undefined), })); - - // Immediately reconcile against the current state to close any gap - // between turn detection and listener registration. The state change - // that triggered server-initiated turn detection may already contain - // response parts (e.g. markdown content) that arrived in the same batch. - const currentState = this._getSessionState(sessionStr); - if (currentState) { - throttler.queue(async () => processState(currentState)); - } } // ---- Turn handling (state-driven) --------------------------------------- @@ -976,7 +890,6 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const turnId = request.requestId; this._clientDispatchedTurnIds.add(turnId); - const cleanUpTurnId = () => this._clientDispatchedTurnIds.delete(turnId); const messageAttachments = this._convertVariablesToAttachments(request); // If the user selected a different model since the session was created @@ -1041,93 +954,43 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC this._ensureEditingSession(request.sessionResource) ?.ensureRequestCheckpoint(request.requestId); - // Track live ChatToolInvocation objects for this turn - const activeToolInvocations = new Map(); - - // Track live input request carousels to cancel if they disappear from state - const activeInputRequests = new Map(); - - // Track last-emitted content lengths per response part to compute deltas - const lastEmittedLengths = new Map(); - - // Track subagent child sessions we're already observing - const observedSubagentToolIds = new Set(); - - const turnDisposables = new DisposableStore(); - - // We throttle updates because generation of edits is async, if this breaks - // layouts if they are not sequenced correctly. - const throttler = new Throttler(); - turnDisposables.add(throttler); - - let resolveDone: () => void; - const done = new Promise(resolve => { resolveDone = resolve; }); - - let finished = false; - const finish = () => throttler.queue(async () => { - if (finished) { - return; - } - finished = true; - cleanUpTurnId(); - // Finalize any outstanding tool invocations - for (const [, invocation] of activeToolInvocations) { - invocation.didExecuteTool(undefined); - } - activeToolInvocations.clear(); - turnDisposables.dispose(); - resolveDone(); - }); - - // Listen to state changes and translate to IChatProgress[] - const handleTurnSub = this._ensureSessionSubscription(session.toString()); - turnDisposables.add(handleTurnSub.onWillApplyAction(envelope => this._applyCompletedInputRequest(activeInputRequests, envelope.action as SessionAction))); - const ctx: ITurnProcessingContext = { - turnId, - backendSession: session, - sessionResource: request.sessionResource, - activeToolInvocations, - lastEmittedLengths, - progress, - cancellationToken, - onFileEdits: (tc, fileEdits) => { - const editParts = this._hydrateFileEdits(request.sessionResource, request.requestId, tc); - if (editParts.length > 0) { - progress(editParts); - } - }, - }; - - turnDisposables.add(handleTurnSub.onDidChange(rawSessionState => { - throttler.queue(async () => { - if (cancellationToken.isCancellationRequested) { - return; - } - const isActive = this._processSessionState(rawSessionState, ctx); - - // Process input requests (ask_user tool elicitations) - this._syncInputRequests(activeInputRequests, rawSessionState.inputRequests, session, request.sessionResource, cancellationToken, progress); - - // Observe subagent sessions for subagent tool calls - this._observeSubagentToolCalls(rawSessionState, turnId, activeToolInvocations, observedSubagentToolIds, session, progress, turnDisposables); - - if (!isActive && !finished) { - finish(); - } - }); - })); + // Wait for the turn to reach a terminal state. The observable graph + // installed below drives all progress emission via the `progress` + // sink and resolves the promise from `onTurnEnded`. Cancellation is + // surfaced through the same path: the observer disposes itself when + // `cancellationToken` fires, then calls `onTurnEnded(undefined)`. + await new Promise(resolve => { + const store = new DisposableStore(); + const cancelSub = store.add(cancellationToken.onCancellationRequested(() => { + cancelSub.dispose(); + this._logService.info(`[AgentHost] Cancellation requested for ${session.toString()}, dispatching turnCancelled`); + this._config.connection.dispatch({ + type: ActionType.SessionTurnCancelled, + session: session.toString(), + turnId, + }); + })); - turnDisposables.add(cancellationToken.onCancellationRequested(() => { - this._logService.info(`[AgentHost] Cancellation requested for ${session.toString()}, dispatching turnCancelled`); - this._config.connection.dispatch({ - type: ActionType.SessionTurnCancelled, - session: session.toString(), + store.add(this._observeTurn({ + backendSession: session, + sessionResource: request.sessionResource, turnId, - }); - finish(); - })); - - await done; + sink: progress, + cancellationToken, + onTurnEnded: () => { + store.dispose(); + this._clientDispatchedTurnIds.delete(turnId); + this._activeSessions.get(request.sessionResource)?.isCompleteObs.set(true, undefined); + resolve(); + }, + onFileEdits: (tc) => { + const editParts = this._hydrateFileEdits(request.sessionResource, request.requestId, tc); + if (editParts.length > 0) { + progress(editParts); + } + }, + })); + }); } // ---- Tool confirmation -------------------------------------------------- @@ -1184,91 +1047,366 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC }); } - // ---- Tool call state updates -------------------------------------------- + // ---- Per-turn observable graph ------------------------------------------ /** - * Shared logic for updating an existing {@link ChatToolInvocation} when - * the protocol tool-call state changes. Handles: - * - PendingConfirmation re-confirmation (Running → PendingConfirmation) - * - Running status: updates invocation message and detects terminal content - * - Completed/Cancelled: revives terminal if needed, then finalizes + * Installs the always-on observable graph that translates session state + * into `IChatProgress[]` for a specific turn. The same graph is used for: + * - live turns started by the user via {@link _handleTurn}, + * - reconnect to an in-flight turn from {@link provideChatSessionContent}, + * - server-initiated turns detected by {@link _watchForServerInitiatedTurns}. * - * @returns Updated invocation (may differ from `existing` on re-confirmation) - * and file edits produced by finalization. + * Differences are captured in {@link IObserveTurnOptions.sink} (where + * progress is delivered) and {@link IObserveTurnOptions.adoptInvocations} / + * {@link IObserveTurnOptions.seedEmittedLengths} (snapshot continuity for + * the reconnect case). + * + * The returned disposable owns the entire per-turn graph, including the + * underlying session subscription reference. */ - private _updateToolCallState( - existing: ChatToolInvocation, - tc: ToolCallState, - ctx: ITurnProcessingContext, - ): { invocation: ChatToolInvocation; fileEdits: IToolCallFileEdit[] } { - const toolCallId = tc.toolCallId; - let fileEdits: IToolCallFileEdit[] = []; - - if (tc.status === ToolCallStatus.PendingConfirmation) { - // Running → PendingConfirmation (re-confirmation). - const existingState = existing.state.get(); - if (existingState.type !== IChatToolInvocation.StateKind.WaitingForConfirmation) { - existing.didExecuteTool(undefined); - const confirmInvocation = toolCallStateToInvocation(tc, undefined, ctx.backendSession, this._config.connectionAuthority); - ctx.activeToolInvocations.set(toolCallId, confirmInvocation); - ctx.progress([confirmInvocation]); - this._awaitToolConfirmation(confirmInvocation, toolCallId, ctx.backendSession, ctx.turnId, ctx.cancellationToken, tc.options); - existing = confirmInvocation; + private _observeTurn(opts: IObserveTurnOptions): IDisposable { + const sessionKey = opts.backendSession.toString(); + const store = new DisposableStore(); + // `_ensureSessionSubscription` returns a process-shared, non-refcounted + // subscription owned by the chat session lifecycle. Do NOT release it + // from here — other callers (the server-turn watcher, reconnect, the + // history hydration code) share the same instance and would lose + // their state if we tore it down. + const sub = this._ensureSessionSubscription(sessionKey); + + const sessionState$ = observableFromSubscription(this, sub); + const turn$ = derived(reader => { + const state = sessionState$.read(reader); + if (!state) { + return undefined; } - } else if (tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.PendingResultConfirmation) { - existing.invocationMessage = stringOrMarkdownToString(tc.invocationMessage, this._config.connectionAuthority); - this._reviveTerminalIfNeeded(existing, tc, ctx.backendSession); - updateRunningToolSpecificData(existing, tc, this._config.connectionAuthority); - } + return state.activeTurn?.id === opts.turnId + ? state.activeTurn + : state.turns.find(t => t.id === opts.turnId); + }); + const responseParts$ = derived(reader => turn$.read(reader)?.responseParts ?? []); + const inputRequests$ = derived(reader => sessionState$.read(reader)?.inputRequests ?? []); - // Finalize terminal-state tools - if ((tc.status === ToolCallStatus.Completed || tc.status === ToolCallStatus.Cancelled) && !IChatToolInvocation.isComplete(existing)) { - // Revive terminal before finalizing — handles the case where - // Running was skipped due to throttling and terminal content - // only appears at Completed time. - this._reviveTerminalIfNeeded(existing, tc, ctx.backendSession); - fileEdits = finalizeToolInvocation(existing, tc, ctx.backendSession, this._config.connectionAuthority); - } + // Per-tool-call subagent observation dedup. A tool call may fire the + // per-key autorun multiple times; only install the child observer once. + const observedSubagentToolIds = new Set(); + + // Per response part. Markdown / reasoning / tool calls each get a + // dedicated setup keyed by their stable id. Per-key closures replace + // the `Map` and `Map + // lastEmittedLengths` bookkeeping that used to live on every call + // site of `_processSessionState`. + store.add(autorunPerKeyedItem( + responseParts$, + rp => rp.kind === ResponsePartKind.ToolCall + ? `tc:${rp.toolCall.toolCallId}` + : rp.kind === ResponsePartKind.Markdown + ? `md:${rp.id}` + : rp.kind === ResponsePartKind.Reasoning + ? `rs:${rp.id}` + : `other:${responseParts$.get().indexOf(rp)}`, + (_key, part$, partStore) => { + const initial = part$.get(); + switch (initial.kind) { + case ResponsePartKind.Markdown: + // Subagent observers don't forward markdown into the + // parent's progress — it belongs to the subagent's own + // session view. + if (opts.subAgentInvocationId !== undefined) { + break; + } + this._setupMarkdownPart(part$ as IObservable, partStore, opts); + break; + case ResponsePartKind.Reasoning: + if (opts.subAgentInvocationId !== undefined) { + break; + } + this._setupReasoningPart(part$ as IObservable, partStore, opts); + break; + case ResponsePartKind.ToolCall: + this._setupToolCallPart(part$ as IObservable, partStore, opts, observedSubagentToolIds); + break; + } + }, + )); + + // Per input request carousel. Skipped for subagent observers — input + // requests on a subagent session are surfaced through that session's + // own view, not the parent. + if (opts.subAgentInvocationId === undefined) { + store.add(autorunPerKeyedItem( + inputRequests$, + ir => ir.id, + (_id, ir$, irStore) => { + this._setupInputRequest(ir$.get(), irStore, opts); + }, + )); + } + + // Detect terminal turn state. The turn is over when the active turn + // id no longer matches our turn id; the completed turn (if present + // in `turns`) surfaces any error message. + // + // `seenActive` guards against firing `finish` on the install pass: + // `_handleTurn` calls us right after dispatching `SessionTurnStarted` + // but before the action has been echoed back, so the very first + // reading of state may not yet contain our turn. We must wait until + // we've seen our turn become active at least once before treating + // its absence as a terminal transition. + let terminated = false; + let seenActive = false; + const finish = (lastTurn: Turn | undefined) => { + if (terminated) { + return; + } + terminated = true; + // Defer to a microtask so any other autoruns reacting to the + // same state update (e.g. tool call finalization) finish first. + // Self-dispose afterwards so callers do not need to track us + // across the natural-completion path; cancellation paths can + // still call `dispose()` proactively (idempotent). + queueMicrotask(() => { + try { + opts.onTurnEnded?.(lastTurn); + } finally { + store.dispose(); + } + }); + }; + store.add(autorun(reader => { + if (terminated) { + return; + } + const state = sessionState$.read(reader); + if (!state) { + return; + } + if (state.activeTurn?.id === opts.turnId) { + seenActive = true; + return; + } + // Also treat a completed turn we discover in `turns` as + // "having seen it", so reconnect / server-initiated paths that + // install us against an already-completed turn still finish. + const lastTurn = state.turns.find(t => t.id === opts.turnId); + if (lastTurn) { + seenActive = true; + } + if (!seenActive) { + return; + } + if (lastTurn?.state === TurnState.Error && lastTurn.error) { + opts.sink([{ kind: 'markdownContent', content: new MarkdownString(`\n\nError: (${lastTurn.error.errorType}) ${lastTurn.error.message}`) }]); + } + finish(lastTurn); + })); + + store.add(opts.cancellationToken.onCancellationRequested(() => finish(undefined))); + + return store; + } + + private _setupMarkdownPart( + part$: IObservable, + store: DisposableStore, + opts: IObserveTurnOptions, + ): void { + // Seed from the snapshot length so the always-on graph does not + // re-emit content already covered by `activeTurnToProgress` on + // reconnect. + let lastEmitted = opts.seedEmittedLengths?.get(part$.get().id) ?? 0; + store.add(autorun(reader => { + const content = part$.read(reader).content; + if (content.length <= lastEmitted) { + return; + } + const delta = content.substring(lastEmitted); + lastEmitted = content.length; + // supportHtml is load bearing. Without this the markdown string + // gets merged into the edit part in chatModel.ts which breaks + // rendering because the thinking content part does not deal + // with this. + opts.sink([{ kind: 'markdownContent', content: rawMarkdownToString(delta, this._config.connectionAuthority, { supportHtml: true }) }]); + })); + } - return { invocation: existing, fileEdits }; + private _setupReasoningPart( + part$: IObservable, + store: DisposableStore, + opts: IObserveTurnOptions, + ): void { + let lastEmitted = opts.seedEmittedLengths?.get(part$.get().id) ?? 0; + store.add(autorun(reader => { + const content = part$.read(reader).content; + if (content.length <= lastEmitted) { + return; + } + const delta = content.substring(lastEmitted); + lastEmitted = content.length; + opts.sink([{ kind: 'thinking', value: delta }]); + })); } - // ---- Client tool execution ---------------------------------------------- + private _setupToolCallPart( + part$: IObservable, + store: DisposableStore, + opts: IObserveTurnOptions, + observedSubagentToolIds: Set, + ): void { + const initial = part$.get().toolCall; + // Subagent observers always treat tool calls as server-driven — even if + // `toolClientId` happens to match the local client, we don't want to + // invoke the tool locally a second time. Legacy behavior was the same. + if (opts.subAgentInvocationId === undefined && initial.toolClientId === this._config.connection.clientId) { + this._setupClientToolCall(initial, part$, store, opts); + } else { + this._setupServerToolCall(initial, part$, store, opts, observedSubagentToolIds); + } + } /** - * Begin a client-provided tool invocation locally. Creates a - * {@link ChatToolInvocation} in the streaming state via - * {@link ILanguageModelToolsService.beginToolCall} so the UI has a - * handle as soon as the tool call first appears in session state. - * - * A single autorun observes the invocation's state machine and drives - * all protocol dispatches: - * - `Executing` → `SessionToolCallConfirmed(approved: true)` - * - `Cancelled` (pre-execution denial) → `SessionToolCallConfirmed(approved: false)` - * - `Completed` / post-execution `Cancelled` → `SessionToolCallComplete` - * - * When the tool call is cancelled externally via session state, the - * entry's cancellation source is fired and `CancellationTokenSource` is - * cancelled so the autorun skips redundant dispatches (the server already knows). - * - * The actual {@link ILanguageModelToolsService.invokeTool} call is - * deferred until {@link _tryInvokeClientTool} sees the tool parameters. + * Per-call setup for a server-driven tool. Adopts a snapshot + * {@link ChatToolInvocation} when present (reconnect parity); otherwise + * emits a fresh one. Reacts to status transitions for re-confirmation, + * terminal revival, finalization, and subagent observation. */ - private _beginClientToolInvocation(tc: ToolCallState, ctx: ITurnProcessingContext): void { - const toolCallId = tc.toolCallId; + private _setupServerToolCall( + initial: ToolCallState, + part$: IObservable, + store: DisposableStore, + opts: IObserveTurnOptions, + observedSubagentToolIds: Set, + ): void { + const toolCallId = initial.toolCallId; + const subAgentInvocationId = opts.subAgentInvocationId; + const adopted = opts.adoptInvocations?.get(toolCallId); + let invocation = adopted + ?? toolCallStateToInvocation(initial, subAgentInvocationId, opts.backendSession, this._config.connectionAuthority); + if (!adopted) { + opts.sink([invocation]); + } + + const tryObserveSubagent = (tc: ToolCallState) => { + // Don't recurse into nested subagents \u2014 legacy behavior was to + // only observe the immediate child session, not children of + // children. + if (subAgentInvocationId !== undefined) { + return; + } + if (observedSubagentToolIds.has(toolCallId)) { + return; + } + const isSub = isSubagentTool(tc) + || ((tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.Completed) && getToolSubagentContent(tc)); + if (!isSub) { + return; + } + observedSubagentToolIds.add(toolCallId); + this._observeSubagentSession(opts.backendSession, toolCallId, opts.sink, store, observedSubagentToolIds); + }; + + // Initial confirmation hookup. The autorun below only handles + // *transitions* back into `PendingConfirmation` (server-driven + // re-confirmation), not the initial state, because + // `toolCallStateToInvocation` already created the invocation in + // `WaitingForConfirmation`. Without this explicit call, no listener + // would observe the user's confirmation answer. + if (initial.status === ToolCallStatus.PendingConfirmation && !IChatToolInvocation.isComplete(invocation)) { + this._awaitToolConfirmation(invocation, toolCallId, opts.backendSession, opts.turnId, opts.cancellationToken, initial.options); + } + tryObserveSubagent(initial); + + // Stream subsequent status transitions. Re-confirmation is detected + // from a `tc.status` transition (Running → PendingConfirmation), not + // from comparing against `invocation.state`: the user's local + // confirmation flips `invocation.state` to `Executing` before the + // server echoes Running, and a state-comparison check would + // spuriously trigger re-confirmation in that gap. + let previousStatus: ToolCallStatus | undefined; + store.add(autorun(reader => { + const tc = part$.read(reader).toolCall; + const status = tc.status; + const isReconfirmation = previousStatus !== undefined + && previousStatus !== ToolCallStatus.PendingConfirmation + && status === ToolCallStatus.PendingConfirmation; + previousStatus = status; + + if (isReconfirmation) { + // Server bounced the call back to PendingConfirmation + // (e.g. write confirmation after edit). Settle the old + // invocation and replace it with a fresh one carrying the + // new confirmation messages. + invocation.didExecuteTool(undefined); + const confirmInvocation = toolCallStateToInvocation(tc, subAgentInvocationId, opts.backendSession, this._config.connectionAuthority); + opts.sink([confirmInvocation]); + this._awaitToolConfirmation(confirmInvocation, toolCallId, opts.backendSession, opts.turnId, opts.cancellationToken, tc.options); + invocation = confirmInvocation; + } else if (status === ToolCallStatus.Running || status === ToolCallStatus.PendingResultConfirmation) { + invocation.invocationMessage = stringOrMarkdownToString(tc.invocationMessage, this._config.connectionAuthority); + this._reviveTerminalIfNeeded(invocation, tc, opts.backendSession); + updateRunningToolSpecificData(invocation, tc, this._config.connectionAuthority); + } + + if ((status === ToolCallStatus.Completed || status === ToolCallStatus.Cancelled) && !IChatToolInvocation.isComplete(invocation)) { + // Revive terminal before finalizing — handles the case where + // Running was skipped (e.g. throttling) and terminal content + // only appears at Completed time. + this._reviveTerminalIfNeeded(invocation, tc, opts.backendSession); + const fileEdits = finalizeToolInvocation(invocation, tc, opts.backendSession, this._config.connectionAuthority); + if (fileEdits.length > 0) { + opts.onFileEdits?.(tc, fileEdits); + } + } - const toolData = this._toolsService.getToolByName(tc.toolName); + tryObserveSubagent(tc); + })); + + // If the turn ends with the tool still mid-flight (e.g. external + // cancellation), settle the invocation so the UI does not get stuck. + store.add(toDisposable(() => { + if (!IChatToolInvocation.isComplete(invocation)) { + invocation.didExecuteTool(undefined); + } + })); + } + + /** + * Per-call setup for a client-provided tool. Eagerly creates a streaming + * {@link ChatToolInvocation} so the UI has a handle, then invokes the + * tool once parameters are available. The inner autorun on `part$` is + * idempotent: `invoked` ensures `invokeTool` runs at most once, + * `confirmationDispatched` ensures `SessionToolCallConfirmed` is sent at + * most once. + */ + private _setupClientToolCall( + initial: ToolCallState, + part$: IObservable, + store: DisposableStore, + opts: IObserveTurnOptions, + ): void { + const toolCallId = initial.toolCallId; + const toolName = initial.toolName; + + // Reconnect adoption: settle any snapshot invocation so the new + // streaming one created by `beginToolCall` can take over the UI + // slot rather than leaving the old instance orphaned. + const adopted = opts.adoptInvocations?.get(toolCallId); + if (adopted && !IChatToolInvocation.isComplete(adopted)) { + adopted.didExecuteTool(undefined); + } + + const toolData = this._toolsService.getToolByName(toolName); if (!toolData) { - this._logService.warn(`[AgentHost] Client tool call for unknown tool: ${tc.toolName}`); + this._logService.warn(`[AgentHost] Client tool call for unknown tool: ${toolName}`); this._dispatchAction({ type: ActionType.SessionToolCallComplete, - session: ctx.backendSession.toString(), - turnId: ctx.turnId, + session: opts.backendSession.toString(), + turnId: opts.turnId, toolCallId, result: { success: false, - pastTenseMessage: `Tool "${tc.toolName}" is not available`, - error: { message: `Tool "${tc.toolName}" is not available on this client` }, + pastTenseMessage: `Tool "${toolName}" is not available`, + error: { message: `Tool "${toolName}" is not available on this client` }, }, }); return; @@ -1277,511 +1415,177 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const invocation = this._toolsService.beginToolCall({ toolCallId, toolId: toolData.id, - sessionResource: ctx.sessionResource, + sessionResource: opts.sessionResource, force: true, }) as ChatToolInvocation | undefined; if (!invocation) { - this._logService.warn(`[AgentHost] Failed to begin client tool invocation: ${tc.toolName}`); + this._logService.warn(`[AgentHost] Failed to begin client tool invocation: ${toolName}`); this._dispatchAction({ type: ActionType.SessionToolCallComplete, - session: ctx.backendSession.toString(), - turnId: ctx.turnId, + session: opts.backendSession.toString(), + turnId: opts.turnId, toolCallId, result: { success: false, - pastTenseMessage: `Failed to start ${tc.toolName}`, - error: { message: `Could not create invocation for client tool "${tc.toolName}"` }, + pastTenseMessage: `Failed to start ${toolName}`, + error: { message: `Could not create invocation for client tool "${toolName}"` }, }, }); return; } - const disposables = new DisposableStore(); const cts = new CancellationTokenSource(); - disposables.add(toDisposable(() => cts.dispose(true))); + store.add(toDisposable(() => cts.dispose(true))); - const entry: IClientToolCallEntry = { - invocation, - disposables, - cts, - toolName: tc.toolName, - invoked: false, - approvedDispatched: false, - }; - this._clientToolCalls.set(toolCallId, entry); - ctx.activeToolInvocations.set(toolCallId, invocation); - - // Autorun drives the `SessionToolCallConfirmed` dispatch only. The - // `SessionToolCallComplete` dispatch happens from `_handleClientToolSettled` - // after `invokeTool` has resolved (or rejected) and the result/error is - // actually available — otherwise we would race the microtask that - // stashes the result onto the entry against the synchronous state - // transition `didExecuteTool(...)` makes when the tool finishes. + let invoked = false; + let approvedDispatched = false; let confirmationDispatched = false; - disposables.add(autorun(reader => { + + // Drive `SessionToolCallConfirmed` from the invocation's confirmation + // gate. The autorun runs synchronously many times; the guards keep it + // idempotent. + store.add(autorun(reader => { const state = invocation.state.read(reader); if (confirmationDispatched) { return; } if (state.type === IChatToolInvocation.StateKind.Executing) { confirmationDispatched = true; - if (!cts.token.isCancellationRequested) { - entry.approvedDispatched = true; - this._dispatchAction({ - type: ActionType.SessionToolCallConfirmed, - session: ctx.backendSession.toString(), - turnId: ctx.turnId, - toolCallId, - approved: true, - confirmed: confirmedReasonToProtocol(state.confirmed), - }); + if (cts.token.isCancellationRequested) { + return; } + approvedDispatched = true; + this._dispatchAction({ + type: ActionType.SessionToolCallConfirmed, + session: opts.backendSession.toString(), + turnId: opts.turnId, + toolCallId, + approved: true, + confirmed: confirmedReasonToProtocol(state.confirmed), + }); } else if (state.type === IChatToolInvocation.StateKind.Cancelled) { - // Pre-execution cancellation. Two sub-cases: - // 1. User denied (or a hook denied): dispatch `approved: false` - // so the server transitions the call to `Cancelled`. No - // `Complete` dispatch follows — `approvedDispatched` stays - // `false` so the settle handler will skip it. - // 2. The server already reported the call as cancelled and we - // fired our own CTS: `cts.token.isCancellationRequested` is - // `true`, suppress the dispatch (the server already knows) - // and just unwind the local invocation. + // Pre-execution cancellation. If the server already knows + // (cts cancelled), suppress the dispatch — the server + // transitioned the call itself. confirmationDispatched = true; - if (!cts.token.isCancellationRequested) { - this._dispatchAction({ - type: ActionType.SessionToolCallConfirmed, - session: ctx.backendSession.toString(), - turnId: ctx.turnId, - toolCallId, - approved: false, - reason: ToolCallCancellationReason.Denied, - }); - } - // If `invokeTool` was never called (cancel arrived before - // parameters) there is no settle handler to clean us up. - if (!entry.invoked) { - this._disposeClientToolCall(toolCallId); + if (cts.token.isCancellationRequested) { + return; } + this._dispatchAction({ + type: ActionType.SessionToolCallConfirmed, + session: opts.backendSession.toString(), + turnId: opts.turnId, + toolCallId, + approved: false, + reason: ToolCallCancellationReason.Denied, + }); } })); - } - /** - * Invoke the client tool once parameters are available. On the first - * state update that carries `toolInput` (or once the call has moved past - * `Streaming`, to accommodate zero-argument tools that may never carry - * a `toolInput`), parses parameters and calls - * {@link ILanguageModelToolsService.invokeTool}, which reuses the - * streaming invocation created in {@link _beginClientToolInvocation}. - * Settlement is handled by {@link _handleClientToolSettled}. - */ - private _tryInvokeClientTool(tc: ToolCallState, ctx: ITurnProcessingContext): void { - const entry = this._clientToolCalls.get(tc.toolCallId); - if (!entry || entry.invoked || entry.cts.token.isCancellationRequested) { - return; - } - // eslint-disable-next-line local/code-no-in-operator - let toolInput = 'toolInput' in tc ? tc.toolInput : undefined; - if (toolInput === undefined) { - // Don't invoke while still streaming — parameters may still be - // arriving. Once the call has moved to any post-streaming status, - // a missing `toolInput` is treated as an empty JSON object so - // zero-argument tools are not stuck forever. - if (tc.status === ToolCallStatus.Streaming) { + const handleSettled = (result: IToolResult | undefined, err: unknown) => { + if (cts.token.isCancellationRequested) { return; } - toolInput = '{}'; - } - const toolCallId = tc.toolCallId; - entry.invoked = true; - - let parameters: Record = {}; - try { - const parsed: unknown = JSON.parse(toolInput); - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error('expected JSON object'); + if (!approvedDispatched) { + if (err !== undefined && !isCancellationError(err)) { + this._logService.warn(`[AgentHost] Client tool rejected pre-execution: ${toolName}`, err); + } + return; + } + if (err !== undefined) { + if (!isCancellationError(err)) { + this._logService.warn(`[AgentHost] Client tool invocation failed: ${toolName}`, err); + } + const message = err instanceof Error ? err.message : String(err); + result = { content: [], toolResultError: message }; } - parameters = parsed as Record; - } catch { - this._logService.warn(`[AgentHost] Failed to parse tool input for ${tc.toolName}`); this._dispatchAction({ type: ActionType.SessionToolCallComplete, - session: ctx.backendSession.toString(), - turnId: ctx.turnId, + session: opts.backendSession.toString(), + turnId: opts.turnId, toolCallId, - result: { - success: false, - pastTenseMessage: `Failed to execute ${tc.toolName}`, - error: { message: `Invalid tool input for "${tc.toolName}": expected JSON object parameters` }, - }, + result: toolResultToProtocol(result ?? { content: [] }, toolName), }); - this._disposeClientToolCall(toolCallId); - return; - } - - const invocation: IToolInvocation = { - callId: toolCallId, - toolId: entry.invocation.toolId, - parameters, - context: { sessionResource: ctx.sessionResource }, - chatStreamToolCallId: toolCallId, }; - const noOpCountTokens = async () => 0; - - this._logService.info(`[AgentHost] Invoking client tool: ${tc.toolName} (callId=${toolCallId})`); - - this._toolsService.invokeTool( - invocation, - noOpCountTokens, - entry.cts.token, - ).then( - result => this._handleClientToolSettled(toolCallId, ctx, result, undefined), - err => this._handleClientToolSettled(toolCallId, ctx, undefined, err), - ); - } - - /** - * Called when {@link ILanguageModelToolsService.invokeTool} settles for - * a client tool. Dispatches `SessionToolCallComplete` when appropriate, - * then disposes the entry. Rules: - * - * - External cancellation (`cts.token.isCancellationRequested`): the - * server already marked the call as `Cancelled`; suppress all - * dispatches and just clean up. - * - Approved path (`approvedDispatched === true`): the tool actually - * ran (or attempted to run), so dispatch the result/error as a - * `Complete`. - * - Pre-execution denial (`approvedDispatched === false`, - * `CancellationError`): the autorun already dispatched - * `Confirmed(approved: false)`; the server transitions the call - * itself, no `Complete` follows. - */ - private _handleClientToolSettled( - toolCallId: string, - ctx: ITurnProcessingContext, - result: IToolResult | undefined, - err: unknown, - ): void { - const entry = this._clientToolCalls.get(toolCallId); - if (!entry) { - return; - } - - if (entry.cts.token.isCancellationRequested) { - this._disposeClientToolCall(toolCallId); - return; - } - - if (!entry.approvedDispatched) { - if (err !== undefined && !isCancellationError(err)) { - this._logService.warn(`[AgentHost] Client tool rejected pre-execution: ${entry.toolName}`, err); - } - this._disposeClientToolCall(toolCallId); - return; - } - - if (err !== undefined) { - if (!isCancellationError(err)) { - this._logService.warn(`[AgentHost] Client tool invocation failed: ${entry.toolName}`, err); - } - const message = err instanceof Error ? err.message : String(err); - result = { content: [], toolResultError: message }; - } - - this._dispatchAction({ - type: ActionType.SessionToolCallComplete, - session: ctx.backendSession.toString(), - turnId: ctx.turnId, - toolCallId, - result: toolResultToProtocol(result ?? { content: [] }, entry.toolName), - }); - this._disposeClientToolCall(toolCallId); - } - - /** - * Cancel an ongoing client-tool invocation in response to the session - * state reporting the tool call as cancelled. Fires the entry's - * cancellation source — this signals both - * {@link ILanguageModelToolsService.invokeTool} (which unwinds and - * rejects with a {@link CancellationError}) and the autorun / settle - * handler (via `cts.token.isCancellationRequested`) to skip any - * redundant protocol dispatches. If `invokeTool` was never called - * (cancel arrived before parameters), we also have to force the local - * invocation out of `Streaming` and dispose ourselves, since no - * settle handler will run. - */ - private _cancelClientToolInvocation(toolCallId: string): void { - const entry = this._clientToolCalls.get(toolCallId); - if (!entry || entry.cts.token.isCancellationRequested) { - return; - } - entry.cts.cancel(); - if (!entry.invoked) { - // No `invokeTool` is listening to the CTS — transition the - // invocation to `Cancelled` ourselves so the UI settles, then - // clean up. The autorun will fire on the state change but - // suppresses its dispatch because `isCancellationRequested` is - // now true. - entry.invocation.cancelFromStreaming(ToolConfirmKind.Skipped); - this._disposeClientToolCall(toolCallId); - } - } - - private _disposeClientToolCall(toolCallId: string): void { - const entry = this._clientToolCalls.get(toolCallId); - if (entry) { - entry.disposables.dispose(); - this._clientToolCalls.delete(toolCallId); - } - } - - /** - * Detects terminal content in a tool call and creates a local terminal - * instance backed by the agent host connection. Updates the invocation's - * `toolSpecificData` to `kind: 'terminal'` and clears - * `HiddenAfterComplete` so the terminal UI stays visible. - */ - private _reviveTerminalIfNeeded( - invocation: ChatToolInvocation, - tc: ToolCallState, - backendSession: URI, - ): void { - // content is only present on Running/Completed/PendingResultConfirmation. - // toolInput is present on all post-streaming states. - if (tc.status !== ToolCallStatus.Running && tc.status !== ToolCallStatus.Completed && tc.status !== ToolCallStatus.PendingResultConfirmation) { - return; - } - const terminalUri = getTerminalContentUri(tc.content); - if (!terminalUri || !tc.toolInput) { - return; - } - invocation.presentation = undefined; - const toolInput = tc.toolInput; - this._ensureTerminalInstance(terminalUri, backendSession).then(sessionId => { - const existing = invocation.toolSpecificData?.kind === 'terminal' - ? invocation.toolSpecificData as IChatTerminalToolInvocationData - : undefined; - - // Resolve the terminalCommandId from the AHP command source - let terminalCommandId = existing?.terminalCommandId; - if (!terminalCommandId) { - const source = this._terminalChatService.getAhpCommandSource(sessionId); - if (source) { - // Use the executing command or the most recent completed command - const cmd = source.executingCommandObject ?? source.commands[source.commands.length - 1]; - terminalCommandId = cmd?.id; + // React to part$ updates: route external cancellation, and try to + // invoke once parameters are present. Idempotent via `invoked` and + // `cts.token.isCancellationRequested`. + store.add(autorun(reader => { + const tc = part$.read(reader).toolCall; + if (tc.status === ToolCallStatus.Cancelled) { + if (cts.token.isCancellationRequested) { + return; } - } - - invocation.toolSpecificData = { - ...existing, - kind: 'terminal', - commandLine: { original: toolInput }, - language: 'shellscript', - terminalToolSessionId: sessionId, - terminalCommandUri: URI.parse(terminalUri), - terminalCommandId, - }; - }); - } - - /** - * Processes a session state snapshot for a specific turn, emitting - * incremental progress for new or changed content. Handles markdown - * and reasoning deltas, tool call lifecycle (creation, updates, - * confirmation, finalization), and turn-end error messages. - * - * @returns `true` if the turn is still active, `false` if it has ended. - * When `false`, any error message has already been emitted via - * `progress`. - */ - private _processSessionState( - sessionState: SessionState, - ctx: ITurnProcessingContext, - ): boolean { - const activeTurn = sessionState.activeTurn; - const isActive = activeTurn?.id === ctx.turnId; - const responseParts = isActive - ? activeTurn.responseParts - : sessionState.turns.find(t => t.id === ctx.turnId)?.responseParts; - - if (responseParts) { - for (const rp of responseParts) { - switch (rp.kind) { - case ResponsePartKind.Markdown: { - const lastLen = ctx.lastEmittedLengths.get(rp.id) ?? 0; - if (rp.content.length > lastLen) { - const delta = rp.content.substring(lastLen); - ctx.lastEmittedLengths.set(rp.id, rp.content.length); - // supportHtml is load bearing. Without this the markdown - // string gets merged into the edit part in chatModel.ts - // which breaks rendering because the thinking content - // part does not deal with this. - ctx.progress([{ kind: 'markdownContent', content: rawMarkdownToString(delta, this._config.connectionAuthority, { supportHtml: true }) }]); - } - break; - } - case ResponsePartKind.Reasoning: { - const lastLen = ctx.lastEmittedLengths.get(rp.id) ?? 0; - if (rp.content.length > lastLen) { - const delta = rp.content.substring(lastLen); - ctx.lastEmittedLengths.set(rp.id, rp.content.length); - ctx.progress([{ kind: 'thinking', value: delta }]); - } - break; - } - case ResponsePartKind.ToolCall: { - const tc = rp.toolCall; - let existing = ctx.activeToolInvocations.get(tc.toolCallId); - - if (!existing) { - // Client tools: create a ChatToolInvocation up front in streaming - // state via beginToolCall so the UI has a handle, then invoke - // once tool input is available. invokeTool reuses the pending - // invocation, so there is a single UI throughout the lifecycle. - if (tc.toolClientId === this._config.connection.clientId) { - this._beginClientToolInvocation(tc, ctx); - this._tryInvokeClientTool(tc, ctx); - break; - } - - existing = toolCallStateToInvocation(tc, undefined, ctx.backendSession, this._config.connectionAuthority); - ctx.activeToolInvocations.set(tc.toolCallId, existing); - ctx.progress([existing]); - - if (tc.status === ToolCallStatus.PendingConfirmation) { - this._awaitToolConfirmation(existing, tc.toolCallId, ctx.backendSession, ctx.turnId, ctx.cancellationToken, tc.options); - } else { - // First snapshot may already be Running/Completed/ - // Cancelled (due to throttling). Process immediately - // so terminal revival and finalization still happen. - const { fileEdits } = this._updateToolCallState(existing, tc, ctx); - if (fileEdits.length > 0) { - ctx.onFileEdits?.(tc, fileEdits); - } - } - } else { - // Client tools: invokeTool owns the UI lifecycle once invoked. - // Drive invocation from later updates when toolInput arrives, - // and cancel locally if the server reports the call cancelled. - if (this._clientToolCalls.has(tc.toolCallId)) { - if (tc.status === ToolCallStatus.Cancelled) { - this._cancelClientToolInvocation(tc.toolCallId); - } else { - this._tryInvokeClientTool(tc, ctx); - } - break; - } - const { fileEdits } = this._updateToolCallState(existing, tc, ctx); - if (fileEdits.length > 0) { - ctx.onFileEdits?.(tc, fileEdits); - } - } - break; - } + cts.cancel(); + if (!invoked) { + // No `invokeTool` is listening to the CTS — transition + // the invocation to `Cancelled` ourselves. + invocation.cancelFromStreaming(ToolConfirmKind.Skipped); } + return; } - } - - if (!isActive) { - const lastTurn = sessionState.turns.find(t => t.id === ctx.turnId); - if (lastTurn?.state === TurnState.Error && lastTurn.error) { - ctx.progress([{ kind: 'markdownContent', content: new MarkdownString(`\n\nError: (${lastTurn.error.errorType}) ${lastTurn.error.message}`) }]); + if (invoked || cts.token.isCancellationRequested) { + return; } - return false; - } - return true; - } - - // ---- Input request handling --------------------------------------------- - - /** - * Syncs the set of active input request carousels against the current - * session state. Cancels carousels whose requests disappeared and creates - * new carousels for newly appeared requests. - */ - private _syncInputRequests( - active: Map, - inputRequests: readonly SessionInputRequest[] | undefined, - session: URI, - sessionResource: URI, - token: CancellationToken, - progress: (items: IChatProgress[]) => void, - ): void { - const currentIds = new Set(inputRequests?.map(r => r.id)); - for (const [id, entry] of active) { - if (!currentIds.has(id)) { - if (!entry.carousel.isUsed) { - entry.completedFromState = true; - entry.carousel.data = {}; - entry.carousel.isUsed = true; - entry.carousel.draftAnswers = undefined; - entry.carousel.draftCurrentIndex = undefined; - entry.carousel.draftCollapsed = undefined; - entry.carousel.completion.complete({ answers: undefined }); - } - if (entry.completedFromState) { - this._chatWidgetService.getWidgetBySessionResource(sessionResource)?.input.clearQuestionCarousel(undefined, id); + // eslint-disable-next-line local/code-no-in-operator + let toolInput = 'toolInput' in tc ? tc.toolInput : undefined; + if (toolInput === undefined) { + // Still streaming — parameters may still be arriving. Once + // we move past Streaming, treat a missing toolInput as `{}` + // so zero-argument tools are not stuck. + if (tc.status === ToolCallStatus.Streaming) { + return; } - active.delete(id); + toolInput = '{}'; } - } - if (inputRequests) { - for (const inputReq of inputRequests) { - const entry = active.get(inputReq.id); - if (!entry) { - active.set(inputReq.id, this._handleInputRequest(inputReq, session, token, progress)); - } else { - entry.protocolAnswers = inputReq.answers; + invoked = true; + + let parameters: Record = {}; + try { + const parsed: unknown = JSON.parse(toolInput); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('expected JSON object'); } + parameters = parsed as Record; + } catch { + this._logService.warn(`[AgentHost] Failed to parse tool input for ${toolName}`); + this._dispatchAction({ + type: ActionType.SessionToolCallComplete, + session: opts.backendSession.toString(), + turnId: opts.turnId, + toolCallId, + result: { + success: false, + pastTenseMessage: `Failed to execute ${toolName}`, + error: { message: `Invalid tool input for "${toolName}": expected JSON object parameters` }, + }, + }); + return; } - } - } - /** - * Called from `onWillApplyAction` — **before** the reducer runs — to - * capture the answers from a `SessionInputCompleted` action and stash - * them on the carousel. This must happen pre-reduction because the - * reducer removes the input request from `state.inputRequests` - * entirely; by the time `onDidChange` fires the answers only exist on - * the action payload, which is no longer accessible. - */ - private _applyCompletedInputRequest(active: Map, action: SessionAction): void { - if (action.type !== ActionType.SessionInputCompleted) { - return; - } - const entry = active.get(action.requestId); - if (!entry) { - return; - } - const completedAnswers = action.response === SessionInputResponseKind.Accept - ? (action as SessionInputCompletedAction).answers ?? entry.protocolAnswers - : undefined; - const carouselAnswers = convertProtocolAnswers(completedAnswers); - entry.carousel.data = carouselAnswers ?? {}; - entry.carousel.draftAnswers = undefined; - entry.carousel.draftCurrentIndex = undefined; - entry.carousel.draftCollapsed = undefined; - if (entry.carousel.isUsed) { - return; - } - entry.completedFromState = true; - entry.carousel.isUsed = true; - entry.carousel.completion.complete({ answers: carouselAnswers }); + const inv: IToolInvocation = { + callId: toolCallId, + toolId: invocation.toolId, + parameters, + context: { sessionResource: opts.sessionResource }, + chatStreamToolCallId: toolCallId, + }; + const noOpCountTokens = async () => 0; + this._logService.info(`[AgentHost] Invoking client tool: ${toolName} (callId=${toolCallId})`); + this._toolsService.invokeTool(inv, noOpCountTokens, cts.token).then( + result => handleSettled(result, undefined), + err => handleSettled(undefined, err), + ); + })); } - /** - * Creates a question carousel for a session input request and dispatches - * the `SessionInputCompleted` action when the user answers or cancels. - */ - private _handleInputRequest( + private _setupInputRequest( inputReq: SessionInputRequest, - session: URI, - cancellationToken: CancellationToken, - progress: (items: IChatProgress[]) => void, - ): IActiveInputRequestEntry { + store: DisposableStore, + opts: IObserveTurnOptions, + ): void { const questions: IChatQuestion[] = (inputReq.questions ?? []).map((q): IChatQuestion => { switch (q.kind) { case SessionInputQuestionKind.SingleSelect: @@ -1843,27 +1647,47 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC /* isUsed */ undefined, /* message */ inputReq.message ? rawMarkdownToString(inputReq.message, this._config.connectionAuthority) : undefined, ); - const entry: IActiveInputRequestEntry = { carousel, protocolAnswers: inputReq.answers, completedFromState: false }; - - progress([carousel]); - - if (cancellationToken.isCancellationRequested) { - carousel.completion.complete({ answers: undefined }); - } else { - const tokenListener = cancellationToken.onCancellationRequested(() => { - carousel.completion.complete({ answers: undefined }); - }); - carousel.completion.p.finally(() => tokenListener.dispose()); - } + opts.sink([carousel]); + + // Track the latest server-known answers — initially what was on the + // request when it appeared, then overwritten by `SessionInputCompleted` + // when the server applies it. The disposal path uses this to settle + // the carousel with the server's authoritative answers. + let latestProtocolAnswers: Record | undefined = inputReq.answers; + + // Capture protocol answers from `SessionInputCompleted` BEFORE the + // reducer drops the request from state — by the time disposal runs, + // the action payload is no longer reachable. Also overwrite the + // carousel's `data` so it reflects the server's authoritative answer + // even if the user already locally submitted (mirrors legacy + // `_applyCompletedInputRequest` behavior). + const sub = this._ensureSessionSubscription(opts.backendSession.toString()); + store.add(sub.onWillApplyAction(envelope => { + const action = envelope.action as SessionAction; + if (action.type !== ActionType.SessionInputCompleted || action.requestId !== inputReq.id) { + return; + } + latestProtocolAnswers = action.response === SessionInputResponseKind.Accept + ? (action as SessionInputCompletedAction).answers ?? latestProtocolAnswers + : undefined; + const carouselAnswers = convertProtocolAnswers(latestProtocolAnswers); + carousel.data = carouselAnswers ?? {}; + carousel.draftAnswers = undefined; + carousel.draftCurrentIndex = undefined; + carousel.draftCollapsed = undefined; + })); + // User-driven completion → dispatch `SessionInputCompleted`. The + // state echo (handled above) updates the carousel with the server's + // authoritative answer afterwards. carousel.completion.p.then(result => { - if (entry.completedFromState) { + if (carousel.isUsed) { return; } if (!result.answers) { this._config.connection.dispatch({ type: ActionType.SessionInputCompleted, - session: session.toString(), + session: opts.backendSession.toString(), requestId: inputReq.id, response: SessionInputResponseKind.Cancel, }); @@ -1871,7 +1695,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const answers = convertCarouselAnswers(result.answers); this._config.connection.dispatch({ type: ActionType.SessionInputCompleted, - session: session.toString(), + session: opts.backendSession.toString(), requestId: inputReq.id, response: SessionInputResponseKind.Accept, answers, @@ -1879,45 +1703,86 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } }); - return entry; - } + if (opts.cancellationToken.isCancellationRequested) { + carousel.completion.complete({ answers: undefined }); + } else { + const tokenListener = opts.cancellationToken.onCancellationRequested(() => { + carousel.completion.complete({ answers: undefined }); + }); + carousel.completion.p.finally(() => tokenListener.dispose()); + } - // ---- Subagent child session observation --------------------------------- + // Disposal: the request was either completed (action seen via + // `onWillApplyAction`) or abandoned (turn ended). Settle the + // carousel with whatever server answers we last captured and clear + // the input UI to mirror legacy `_syncInputRequests` behavior. + store.add(toDisposable(() => { + if (carousel.isUsed) { + return; + } + const carouselAnswers = convertProtocolAnswers(latestProtocolAnswers); + carousel.data = carouselAnswers ?? {}; + carousel.isUsed = true; + carousel.draftAnswers = undefined; + carousel.draftCurrentIndex = undefined; + carousel.draftCollapsed = undefined; + carousel.completion.complete({ answers: carouselAnswers }); + this._chatWidgetService.getWidgetBySessionResource(opts.sessionResource)?.input.clearQuestionCarousel(undefined, inputReq.id); + })); + } /** - * Scans the response parts of a turn for subagent tool calls and starts - * observing their child sessions. Deduplicates against previously observed - * tool call IDs. + * Detects terminal content in a tool call and creates a local terminal + * instance backed by the agent host connection. Updates the invocation's + * `toolSpecificData` to `kind: 'terminal'` and clears + * `HiddenAfterComplete` so the terminal UI stays visible. */ - private _observeSubagentToolCalls( - sessionState: SessionState, - turnId: string, - activeToolInvocations: Map, - observedSubagentToolIds: Set, + private _reviveTerminalIfNeeded( + invocation: ChatToolInvocation, + tc: ToolCallState, backendSession: URI, - progress: (parts: IChatProgress[]) => void, - disposables: DisposableStore, ): void { - const activeTurn = sessionState.activeTurn; - const isActiveTurn = activeTurn?.id === turnId; - const parts = isActiveTurn - ? activeTurn.responseParts - : sessionState.turns.find(t => t.id === turnId)?.responseParts; - if (!parts) { + // content is only present on Running/Completed/PendingResultConfirmation. + // toolInput is present on all post-streaming states. + if (tc.status !== ToolCallStatus.Running && tc.status !== ToolCallStatus.Completed && tc.status !== ToolCallStatus.PendingResultConfirmation) { + return; + } + const terminalUri = getTerminalContentUri(tc.content); + if (!terminalUri || !tc.toolInput) { return; } - for (const rp of parts) { - if (rp.kind === ResponsePartKind.ToolCall) { - const tc = rp.toolCall; - const existing = activeToolInvocations.get(tc.toolCallId); - if (existing && !observedSubagentToolIds.has(tc.toolCallId) && (isSubagentTool(tc) || ((tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.Completed) && getToolSubagentContent(tc)))) { - observedSubagentToolIds.add(tc.toolCallId); - this._observeSubagentSession(backendSession, tc.toolCallId, progress, disposables, observedSubagentToolIds); + invocation.presentation = undefined; + const toolInput = tc.toolInput; + this._ensureTerminalInstance(terminalUri, backendSession).then(sessionId => { + const existing = invocation.toolSpecificData?.kind === 'terminal' + ? invocation.toolSpecificData as IChatTerminalToolInvocationData + : undefined; + + // Resolve the terminalCommandId from the AHP command source + let terminalCommandId = existing?.terminalCommandId; + if (!terminalCommandId) { + const source = this._terminalChatService.getAhpCommandSource(sessionId); + if (source) { + // Use the executing command or the most recent completed command + const cmd = source.executingCommandObject ?? source.commands[source.commands.length - 1]; + terminalCommandId = cmd?.id; } } - } + + invocation.toolSpecificData = { + ...existing, + kind: 'terminal', + commandLine: { original: toolInput }, + language: 'shellscript', + terminalToolSessionId: sessionId, + terminalCommandUri: URI.parse(terminalUri), + terminalCommandId, + }; + }); } + // ---- Subagent child session observation --------------------------------- + /** * Enriches serialized history with inner tool calls from subagent child * sessions. For each subagent tool call found in the history, subscribes @@ -2002,6 +1867,14 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC * as progress parts into the parent session's response, with * `subAgentInvocationId` set so the renderer groups them under the parent * subagent widget. + * + * Implementation: builds a per-turn-id keyed observation over the child + * session's `turns` and `activeTurn`. Each turn id discovered gets its + * own {@link _observeTurn} instance running in subagent mode (which skips + * markdown/reasoning/input-request emission and tags tool calls with the + * parent tool call id). Each per-turn observer self-disposes when its + * turn reaches a terminal state; the outer observation is torn down when + * the caller disposes `disposables`. */ private _observeSubagentSession( parentSession: URI, @@ -2011,83 +1884,48 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC observedSet: Set, ): void { const childSessionUri = buildSubagentSessionUri(parentSession.toString(), parentToolCallId); + const childUri = URI.parse(childSessionUri); - const activeChildToolInvocations = new Map(); - const childCts = new CancellationTokenSource(); - disposables.add(toDisposable(() => childCts.dispose(true))); - - // Helper to process response parts from a child turn - const processChildParts = (responseParts: readonly ResponsePart[], turnId: string) => { - for (const rp of responseParts) { - if (rp.kind === ResponsePartKind.ToolCall) { - const tc = rp.toolCall; - let existing = activeChildToolInvocations.get(tc.toolCallId); - - if (!existing) { - existing = toolCallStateToInvocation(tc, parentToolCallId, URI.parse(childSessionUri), this._config.connectionAuthority); - activeChildToolInvocations.set(tc.toolCallId, existing); - emitProgress([existing]); - - if (tc.status === ToolCallStatus.PendingConfirmation) { - this._awaitToolConfirmation(existing, tc.toolCallId, URI.parse(childSessionUri), turnId, childCts.token, tc.options); - } - } else if (tc.status === ToolCallStatus.PendingConfirmation) { - const existingState = existing.state.get(); - if (existingState.type !== IChatToolInvocation.StateKind.WaitingForConfirmation) { - existing.didExecuteTool(undefined); - const confirmInvocation = toolCallStateToInvocation(tc, parentToolCallId, URI.parse(childSessionUri), this._config.connectionAuthority); - activeChildToolInvocations.set(tc.toolCallId, confirmInvocation); - emitProgress([confirmInvocation]); - this._awaitToolConfirmation(confirmInvocation, tc.toolCallId, URI.parse(childSessionUri), turnId, childCts.token, tc.options); - } - } else if (tc.status === ToolCallStatus.Running) { - updateRunningToolSpecificData(existing, tc, this._config.connectionAuthority); - } - - if (existing && (tc.status === ToolCallStatus.Completed || tc.status === ToolCallStatus.Cancelled) && !IChatToolInvocation.isComplete(existing)) { - finalizeToolInvocation(existing, tc, URI.parse(childSessionUri), this._config.connectionAuthority); - } - } - } - }; + const cts = new CancellationTokenSource(); + disposables.add(toDisposable(() => cts.dispose(true))); try { const childSub = this._ensureSessionSubscription(childSessionUri); - - // Attach the state listener BEFORE replaying the snapshot so any - // state change arriving in the gap is not lost. This mirrors the - // pattern used for parent turn observation. - disposables.add(childSub.onDidChange(state => { - if (disposables.isDisposed) { - return; + disposables.add(toDisposable(() => this._releaseSessionSubscription(childSessionUri))); + + const childState$ = observableFromSubscription(this, childSub); + + // All turn ids observed in the child session: completed turns + // plus any active turn that is not also already in `turns`. Each + // id is keyed so `autorunPerKeyedItem` discovers new turns + // incrementally and creates a fresh observer for each. + const childTurnIds$ = derived(reader => { + const state = childState$.read(reader); + if (!state) { + return []; } - - const activeTurn = state.activeTurn; - const turnId = activeTurn?.id ?? state.turns[state.turns.length - 1]?.id; - const responseParts = activeTurn?.responseParts - ?? state.turns[state.turns.length - 1]?.responseParts; - - if (responseParts && turnId) { - processChildParts(responseParts, turnId); - } - })); - - // Replay any existing content from the child session snapshot - // (handles both active turns and already-completed ones) - const childState = this._getSessionState(childSessionUri); - if (childState) { - for (const turn of childState.turns) { - processChildParts(turn.responseParts, turn.id); + const ids: { id: string }[] = state.turns.map(t => ({ id: t.id })); + const activeId = state.activeTurn?.id; + if (activeId !== undefined && !state.turns.some(t => t.id === activeId)) { + ids.push({ id: activeId }); } - if (childState.activeTurn) { - processChildParts(childState.activeTurn.responseParts, childState.activeTurn.id); - } - } + return ids; + }); - // Clean up when disposables are disposed - disposables.add(toDisposable(() => { - this._releaseSessionSubscription(childSessionUri); - })); + disposables.add(autorunPerKeyedItem( + childTurnIds$, + t => t.id, + (turnId, _t$, turnStore) => { + turnStore.add(this._observeTurn({ + backendSession: childUri, + sessionResource: childUri, + turnId, + sink: emitProgress, + cancellationToken: cts.token, + subAgentInvocationId: parentToolCallId, + })); + }, + )); } catch (err) { // Remove from observed set so a later state change can retry observedSet.delete(parentToolCallId); @@ -2112,111 +1950,43 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const sessionKey = backendSession.toString(); // Extract live ChatToolInvocation objects from the initial progress - // array so we can update/finalize the same instances the chat UI holds. - const activeToolInvocations = new Map(); + // array so per-tool setup adopts the same instances the chat UI holds. + const adoptInvocations = new Map(); for (const item of initialProgress) { if (item instanceof ChatToolInvocation) { - activeToolInvocations.set(item.toolCallId, item); + adoptInvocations.set(item.toolCallId, item); } } - // Track last-emitted content lengths per response part to compute deltas. - // Seed from the current state so we only emit new content beyond what - // activeTurnToProgress already captured. - const lastEmittedLengths = new Map(); + // Seed last-emitted markdown/reasoning lengths from the snapshot so + // per-part setup only emits content beyond what `activeTurnToProgress` + // already produced. + const seedEmittedLengths = new Map(); const currentState = this._getSessionState(sessionKey); if (currentState?.activeTurn) { for (const rp of currentState.activeTurn.responseParts) { if (rp.kind === ResponsePartKind.Markdown || rp.kind === ResponsePartKind.Reasoning) { - lastEmittedLengths.set(rp.id, rp.content.length); + seedEmittedLengths.set(rp.id, rp.content.length); } } } - const reconnectDisposables = chatSession.registerDisposable(new DisposableStore()); - const observedSubagentToolIds = new Set(); - const throttler = new Throttler(); - reconnectDisposables.add(throttler); - const cts = new CancellationTokenSource(); - reconnectDisposables.add(toDisposable(() => cts.dispose(true))); - - // Track live input request carousels for reconnection - const activeInputRequests = new Map(); - const appendProgress = (parts: IChatProgress[]) => chatSession.appendProgress(parts); - - // Restore any pending input requests from the initial state - this._syncInputRequests(activeInputRequests, currentState?.inputRequests, backendSession, chatSession.sessionResource, cts.token, appendProgress); - - // Process state changes from the protocol layer. - const ctx: ITurnProcessingContext = { - turnId, + const reconnectStore = chatSession.registerDisposable(new DisposableStore()); + reconnectStore.add(toDisposable(() => cts.dispose(true))); + reconnectStore.add(this._observeTurn({ backendSession, sessionResource: chatSession.sessionResource, - activeToolInvocations, - lastEmittedLengths, - progress: parts => chatSession.appendProgress(parts), + turnId, + sink: parts => chatSession.appendProgress(parts), cancellationToken: cts.token, - }; - - // Wire up tool calls from the initial progress snapshot. - // Client-owned tool calls are re-created through the client tool - // path so _tryInvokeClientTool can execute them. Server tool calls - // get confirmation wiring and subagent observation as before. - for (const [toolCallId, invocation] of activeToolInvocations) { - const tcState = currentState?.activeTurn?.responseParts.find( - rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === toolCallId - ); - const tc = tcState?.kind === ResponsePartKind.ToolCall ? tcState.toolCall : undefined; - - if (tc && tc.toolClientId === this._config.connection.clientId && !IChatToolInvocation.isComplete(invocation)) { - // Complete the snapshot invocation from activeTurnToProgress - // so it does not remain orphaned in the UI — the replacement - // created by _beginClientToolInvocation takes over. - invocation.didExecuteTool(undefined); - this._beginClientToolInvocation(tc, ctx); - this._tryInvokeClientTool(tc, ctx); - continue; - } - - if (!IChatToolInvocation.isComplete(invocation)) { - const tcOptions = tc?.status === ToolCallStatus.PendingConfirmation - ? tc.options - : undefined; - this._awaitToolConfirmation(invocation, toolCallId, backendSession, turnId, cts.token, tcOptions); - } - if (invocation.toolSpecificData?.kind === 'subagent' && !observedSubagentToolIds.has(toolCallId)) { - observedSubagentToolIds.add(toolCallId); - this._observeSubagentSession(backendSession, toolCallId, (parts) => chatSession.appendProgress(parts), reconnectDisposables, observedSubagentToolIds); - } - } - const processStateChange = (sessionState: SessionState) => { - const isActive = this._processSessionState(sessionState, ctx); - this._syncInputRequests(activeInputRequests, sessionState.inputRequests, backendSession, chatSession.sessionResource, cts.token, appendProgress); - - // Observe subagent sessions for subagent tool calls - this._observeSubagentToolCalls(sessionState, turnId, activeToolInvocations, observedSubagentToolIds, backendSession, (parts: IChatProgress[]) => chatSession.appendProgress(parts), reconnectDisposables); - - if (!isActive) { + adoptInvocations, + seedEmittedLengths, + onTurnEnded: () => { chatSession.complete(); - reconnectDisposables.dispose(); - } - }; - - // Attach the ongoing state listener - const reconnectSub = this._ensureSessionSubscription(sessionKey); - reconnectDisposables.add(reconnectSub.onWillApplyAction(envelope => this._applyCompletedInputRequest(activeInputRequests, envelope.action as SessionAction))); - reconnectDisposables.add(reconnectSub.onDidChange(state => { - throttler.queue(async () => processStateChange(state)); + reconnectStore.dispose(); + }, })); - - // Immediately reconcile against the current state to close any gap - // between snapshot time and listener registration. If the turn already - // completed in the interim, this will mark the session complete. - const latestState = this._getSessionState(sessionKey); - if (latestState) { - processStateChange(latestState); - } } // ---- File edit routing --------------------------------------------------- @@ -2656,9 +2426,6 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC ref.dispose(); } this._sessionSubscriptions.clear(); - for (const toolCallId of Array.from(this._clientToolCalls.keys())) { - this._disposeClientToolCall(toolCallId); - } super.dispose(); } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index 1dea65a21723e..6d49a22fefa0b 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts @@ -10,7 +10,7 @@ import { ThemeIcon } from '../../../../../base/common/themables.js'; import { foreground, listActiveSelectionForeground, registerColor, transparent } from '../../../../../platform/theme/common/colorRegistry.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; -import { SessionType } from '../../common/chatSessionsService.js'; +import { isAgentHostTarget, SessionType } from '../../common/chatSessionsService.js'; export enum AgentSessionProviders { Local = SessionType.Local, @@ -118,19 +118,10 @@ export function isFirstPartyAgentSessionProvider(provider: AgentSessionTarget): } /** - * Returns whether the given session type is an agent host target. - * Matches the local agent host (`agent-host-*`) and remote agent hosts (`remote-*`). - * - * Note: The `remote-` prefix convention is established by - * {@link RemoteAgentHostContribution} which generates session types as - * `remote-{sanitizedAddress}-{provider}`. If future remote providers that - * are NOT agent hosts need a different prefix, this function must be updated. + * Re-exported from `common/chatSessionsService.ts` so existing browser-layer + * callers keep working without changing imports. */ -export function isAgentHostTarget(target: string): boolean { - return target === AgentSessionProviders.AgentHostCopilot || - target.startsWith('agent-host-') || - target.startsWith('remote-'); -} +export { isAgentHostTarget }; export function getAgentCanContinueIn(provider: AgentSessionTarget): boolean { switch (provider) { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts index 1d10751a402bb..1250785f188ef 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts @@ -169,8 +169,11 @@ async function appendProviderData(lines: string[], provider: ICustomizationItemP if (item.itemKey) { lines.push(` itemKey: ${item.itemKey}`); } - if (item.extensionLabel) { - lines.push(` extensionLabel: ${item.extensionLabel}`); + if (item.extensionId) { + lines.push(` extensionId: ${item.extensionId}`); + } + if (item.pluginUri) { + lines.push(` pluginUri: ${item.pluginUri.toString()}`); } if (item.badge) { lines.push(` badge: ${item.badge}`); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts index 9815e2e39cced..c5828220b6872 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -62,7 +62,7 @@ export interface IAICustomizationListItem { /** True when item comes from the default chat extension (grouped under Built-in). */ readonly isBuiltin?: boolean; /** Display name of the contributing extension (for non-built-in extension items). */ - readonly extensionLabel?: string; + readonly extensionId?: string; /** Server-reported loading/sync status for remote customizations. */ readonly status?: 'loading' | 'loaded' | 'degraded' | 'error'; /** Human-readable status detail (e.g. error message or warning). */ @@ -157,7 +157,8 @@ export async function expandHookFileItems( groupKey: item.groupKey, storage: item.storage, extensionId: item.extensionId, - pluginUri: item.pluginUri + pluginUri: item.pluginUri, + userInvocable: item.userInvocable, }); } } @@ -199,7 +200,7 @@ export class AICustomizationItemNormalizer { } normalizeItem(item: ICustomizationItem, promptType: PromptsType, uriUseCounts = new ResourceMap()): IAICustomizationListItem { - const { storage, groupKey, isBuiltin, extensionLabel } = this.resolveSource(item); + const { storage, groupKey, isBuiltin, extensionId, pluginUri } = this.inferStorageAndGroup(item); const seenCount = uriUseCounts.get(item.uri) ?? 0; uriUseCounts.set(item.uri, seenCount + 1); const duplicateSuffix = seenCount === 0 ? '' : `#${seenCount}`; @@ -217,64 +218,49 @@ export class AICustomizationItemNormalizer { promptType, disabled: item.enabled === false, groupKey, - pluginUri: storage === PromptsStorage.plugin ? this.findPluginUri(item.uri) : undefined, + pluginUri, displayName: item.name, badge: item.badge, badgeTooltip: item.badgeTooltip, typeIcon: promptType === PromptsType.instructions && storage ? storageToIcon(storage) : undefined, isBuiltin, - extensionLabel, + extensionId, status: item.status, statusMessage: item.statusMessage, }; } - private resolveSource(item: ICustomizationItem): { storage?: PromptsStorage; groupKey?: string; isBuiltin?: boolean; extensionLabel?: string } { - const inferred = this.inferStorageAndGroup(item.uri); + private inferStorageAndGroup(item: ICustomizationItem): { storage: PromptsStorage; groupKey?: string; isBuiltin?: boolean; extensionId?: string; pluginUri?: URI } { + const groupKey = item.groupKey; + const isBuiltin = groupKey === BUILTIN_STORAGE; - // Use provider-supplied values when available; otherwise fall back to URI inference. - const storage = item.storage ?? inferred.storage; - const extensionLabel = item.extensionLabel ?? inferred.extensionLabel; - - if (!item.groupKey) { - return { ...inferred, storage, extensionLabel }; - } - - switch (item.groupKey) { - case BUILTIN_STORAGE: { - // Preserve a provider-supplied BUILTIN_STORAGE so the management - // editor's "edit built-in and save as user/workspace copy" flow - // activates. Otherwise fall back to extension storage (the - // historical source of built-in items). - const builtinStorage = (item.storage as PromptsStorage | typeof BUILTIN_STORAGE | undefined) === BUILTIN_STORAGE - ? (BUILTIN_STORAGE as unknown as PromptsStorage) - : PromptsStorage.extension; - return { storage: builtinStorage, groupKey: BUILTIN_STORAGE, isBuiltin: true, extensionLabel }; + if (item.extensionId) { + const extensionIdentifier = new ExtensionIdentifier(item.extensionId); + if (isChatExtensionItem(extensionIdentifier, this.productService)) { + return { storage: PromptsStorage.extension, groupKey: BUILTIN_STORAGE, isBuiltin: true, extensionId: item.extensionId }; } - default: - return { storage, groupKey: item.groupKey, extensionLabel }; + return { storage: PromptsStorage.extension, extensionId: item.extensionId, groupKey, isBuiltin }; } - } - - private inferStorageAndGroup(uri: URI): { storage?: PromptsStorage; groupKey?: string; isBuiltin?: boolean; extensionLabel?: string } { - if (uri.scheme !== Schemas.file) { - return { storage: PromptsStorage.extension, groupKey: BUILTIN_STORAGE, isBuiltin: true }; + if (item.pluginUri) { + return { storage: PromptsStorage.plugin, pluginUri: item.pluginUri, groupKey, isBuiltin }; } + const uri = item.uri; + const activeProjectRoot = this.workspaceService.getActiveProjectRoot(); if (activeProjectRoot && isEqualOrParent(uri, activeProjectRoot)) { - return { storage: PromptsStorage.local }; + return { storage: PromptsStorage.local, groupKey, isBuiltin }; } for (const folder of this.workspaceContextService.getWorkspace().folders) { if (isEqualOrParent(uri, folder.uri)) { - return { storage: PromptsStorage.local }; + return { storage: PromptsStorage.local, groupKey, isBuiltin }; } } for (const plugin of this.agentPluginService.plugins.get()) { if (isEqualOrParent(uri, plugin.uri)) { - return { storage: PromptsStorage.plugin }; + return { storage: PromptsStorage.plugin, pluginUri: plugin.uri, groupKey, isBuiltin }; } } @@ -282,22 +268,13 @@ export class AICustomizationItemNormalizer { if (extensionId) { const extensionIdentifier = new ExtensionIdentifier(extensionId); if (isChatExtensionItem(extensionIdentifier, this.productService)) { - return { storage: PromptsStorage.extension, groupKey: BUILTIN_STORAGE, isBuiltin: true }; + return { storage: PromptsStorage.extension, groupKey: BUILTIN_STORAGE, isBuiltin: true, extensionId }; } - return { storage: PromptsStorage.extension, extensionLabel: extensionIdentifier.value }; + return { storage: PromptsStorage.extension, extensionId, groupKey, isBuiltin }; } - return { storage: PromptsStorage.user }; } - private findPluginUri(itemUri: URI): URI | undefined { - for (const plugin of this.agentPluginService.plugins.get()) { - if (isEqualOrParent(itemUri, plugin.uri)) { - return plugin.uri; - } - } - return undefined; - } } // #endregion @@ -452,7 +429,8 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour badge: uiTooltip ? uiIntegrationBadge : undefined, badgeTooltip: uiTooltip, extensionId: undefined, - pluginUri: undefined + pluginUri: undefined, + userInvocable: true, }; appended.push(this.itemNormalizer.normalizeItem(builtinItem, promptType, uriUseCounts)); } @@ -488,8 +466,9 @@ export class ProviderCustomizationItemSource implements IAICustomizationItemSour name: getFriendlyName(basename(file.uri)), groupKey: 'sync-local', enabled: true, - extensionId: undefined, - pluginUri: undefined + extensionId: file.extension?.id, + pluginUri: file.pluginUri, + userInvocable: undefined })); return this.itemNormalizer.normalizeItems(providerItems, promptType) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 241a0072b995a..e0885e9cfac4a 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -285,8 +285,8 @@ class AICustomizationItemRenderer implements IListRenderer 0) { @@ -126,7 +128,8 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt badge: uiTooltip ? localize('uiIntegrationBadge', "UI Integration") : undefined, badgeTooltip: uiTooltip, extensionId: file.extension?.identifier.value, - pluginUri: file.pluginUri + pluginUri: file.pluginUri, + userInvocable: false }); } } @@ -145,7 +148,8 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt storage: command.storage, enabled: !disabledUris.has(command.uri), extensionId: command.extension?.identifier.value, - pluginUri: command.pluginUri + pluginUri: command.pluginUri, + userInvocable: command.userInvocable }); if (command.extension) { extensionInfoByUri.set(command.uri, { id: command.extension.identifier, displayName: command.extension.displayName }); @@ -175,7 +179,8 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt storage: f.storage, enabled: !disabledUris.has(f.uri), extensionId: f.extension?.identifier.value, - pluginUri: f.pluginUri + pluginUri: f.pluginUri, + userInvocable: undefined }); } @@ -204,7 +209,8 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt groupKey: 'agents', enabled: !disabledUris.has(agent.uri), extensionId: agent.source.storage === PromptsStorage.extension ? agent.source.extensionId.value : undefined, - pluginUri: agent.source.storage === PromptsStorage.plugin ? agent.source.pluginUri : undefined + pluginUri: agent.source.storage === PromptsStorage.plugin ? agent.source.pluginUri : undefined, + userInvocable: undefined }); } } @@ -232,7 +238,8 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt groupKey: 'agent-instructions', enabled: !disabledUris.has(file.uri), extensionId: undefined, - pluginUri: undefined + pluginUri: undefined, + userInvocable: undefined }); } @@ -261,7 +268,8 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt groupKey: 'context-instructions', enabled: !disabledUris.has(uri), extensionId: extension?.identifier.value, - pluginUri + pluginUri, + userInvocable: undefined }); } else { items.push({ @@ -273,7 +281,8 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt groupKey: 'on-demand-instructions', enabled: !disabledUris.has(uri), extensionId: extension?.identifier.value, - pluginUri + pluginUri, + userInvocable: undefined }); } } diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts index 415dd1370d014..036c20188a796 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts @@ -73,6 +73,15 @@ export interface IChatStatusDashboardOptions { disableProviderOptions?: boolean; /** When true, disables the completions snooze button. */ disableCompletionsSnooze?: boolean; + /** When true, the Quick Settings region is rendered always-expanded without a collapsible header. */ + disableQuickSettingsCollapsible?: boolean; + /** + * When provided, the title header (plan name + manage / CTA actions) is + * rendered into this caller-owned container instead of inline at the top + * of the dashboard. Use this to embed the title header in a host layout + * without reaching into the dashboard's private DOM. + */ + titleHeaderContainer?: HTMLElement; } export class ChatStatusDashboard extends DomWidget { @@ -131,7 +140,8 @@ export class ChatStatusDashboard extends DomWidget { let headerAdditionalSpendButton: Button | undefined; if (hasUsageSection) { const planName = getChatPlanName(this.chatEntitlementService.entitlement); - const header = this.renderHeader(this.element, this._store, planName, toAction({ + const headerHost = this.options?.titleHeaderContainer ?? this.element; + const header = this.renderHeader(headerHost, this._store, planName, toAction({ id: 'workbench.action.manageCopilot', label: localize('quotaLabel', "Manage Chat"), tooltip: localize('quotaTooltip', "Manage Chat"), @@ -263,15 +273,20 @@ export class ChatStatusDashboard extends DomWidget { } private renderQuickSettings(contributedEntries: ChatStatusEntry[]): void { - const collapsed = this.storageService.getBoolean(ChatStatusDashboard.QUICK_SETTINGS_COLLAPSED_KEY, StorageScope.PROFILE, true); + const nonCollapsible = !!this.options?.disableQuickSettingsCollapsible; + const collapsed = !nonCollapsible && this.storageService.getBoolean(ChatStatusDashboard.QUICK_SETTINGS_COLLAPSED_KEY, StorageScope.PROFILE, true); - const disclosureHeader = this.element.appendChild($('button.collapsible-header')); - disclosureHeader.setAttribute('aria-expanded', String(!collapsed)); + let disclosureHeader: HTMLElement | undefined; + let chevron: HTMLElement | undefined; + if (!nonCollapsible) { + disclosureHeader = this.element.appendChild($('button.collapsible-header')); + disclosureHeader.setAttribute('aria-expanded', String(!collapsed)); - const chevron = disclosureHeader.appendChild($('span.collapsible-chevron')); - chevron.classList.add(...ThemeIcon.asClassNameArray(collapsed ? Codicon.chevronRight : Codicon.chevronDown)); + chevron = disclosureHeader.appendChild($('span.collapsible-chevron')); + chevron.classList.add(...ThemeIcon.asClassNameArray(collapsed ? Codicon.chevronRight : Codicon.chevronDown)); - disclosureHeader.appendChild($('span.collapsible-label', undefined, localize('quickSettingsTab', "Quick Settings"))); + disclosureHeader.appendChild($('span.collapsible-label', undefined, localize('quickSettingsTab', "Quick Settings"))); + } const collapsibleContent = this.element.appendChild($('div.collapsible-content')); const collapsibleInner = collapsibleContent.appendChild($('div.collapsible-inner')); @@ -279,15 +294,17 @@ export class ChatStatusDashboard extends DomWidget { collapsibleContent.classList.add('collapsed'); } - const toggle = () => { - const isCollapsed = collapsibleContent.classList.toggle('collapsed'); - disclosureHeader.setAttribute('aria-expanded', String(!isCollapsed)); - chevron.className = 'collapsible-chevron'; - chevron.classList.add(...ThemeIcon.asClassNameArray(isCollapsed ? Codicon.chevronRight : Codicon.chevronDown)); - this.storageService.store(ChatStatusDashboard.QUICK_SETTINGS_COLLAPSED_KEY, isCollapsed, StorageScope.PROFILE, StorageTarget.USER); - }; + if (disclosureHeader && chevron) { + const toggle = () => { + const isCollapsed = collapsibleContent.classList.toggle('collapsed'); + disclosureHeader!.setAttribute('aria-expanded', String(!isCollapsed)); + chevron!.className = 'collapsible-chevron'; + chevron!.classList.add(...ThemeIcon.asClassNameArray(isCollapsed ? Codicon.chevronRight : Codicon.chevronDown)); + this.storageService.store(ChatStatusDashboard.QUICK_SETTINGS_COLLAPSED_KEY, isCollapsed, StorageScope.PROFILE, StorageTarget.USER); + }; - this._store.add(addDisposableListener(disclosureHeader, EventType.CLICK, () => toggle())); + this._store.add(addDisposableListener(disclosureHeader, EventType.CLICK, () => toggle())); + } this.renderInlineSuggestionsContent(collapsibleInner); diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 1bd73f82975ea..d428e71872833 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -42,15 +42,16 @@ import { chatAgentLeader, ChatRequestAgentPart, ChatRequestAgentSubcommandPart, import { ChatRequestParser } from '../requestParser/chatRequestParser.js'; import { ChatMcpServersStarting, ChatPendingRequestChangeClassification, ChatPendingRequestChangeEvent, ChatPendingRequestChangeEventName, ChatRequestQueueKind, ChatSendResult, ChatSendResultQueued, ChatSendResultSent, ChatStopCancellationNoopClassification, ChatStopCancellationNoopEvent, ChatStopCancellationNoopEventName, IChatCompleteResponse, IChatDetail, IChatFollowup, IChatModelReference, IChatProgress, IChatQuestionAnswers, IChatSendRequestOptions, IChatSendRequestResponseState, IChatService, IChatSessionStartOptions, IChatUserActionEvent, ResponseModelState } from './chatService.js'; import { ChatRequestTelemetry, ChatServiceTelemetry } from './chatServiceTelemetry.js'; -import { IChatSessionsService, localChatSessionType } from '../chatSessionsService.js'; +import { IChatSessionsService, isAgentHostTarget, localChatSessionType } from '../chatSessionsService.js'; import { ChatSessionStore, IChatSessionEntryMetadata } from '../model/chatSessionStore.js'; import { IChatSlashCommandService } from '../participants/chatSlashCommands.js'; import { IChatTransferService } from '../model/chatTransferService.js'; import { chatSessionResourceToId, getChatSessionType, isUntitledChatSession, LocalChatSessionUri } from '../model/chatUri.js'; import { ChatRequestVariableSet, IChatRequestVariableEntry, isPromptTextVariableEntry } from '../attachments/chatVariableEntries.js'; +import { IDynamicVariable } from '../attachments/chatVariables.js'; import { ChatAgentLocation, ChatModeKind } from '../constants.js'; import { ChatMessageRole, IChatMessage, ILanguageModelsService } from '../languageModels.js'; -import { ILanguageModelToolsService } from '../tools/languageModelToolsService.js'; +import { ILanguageModelToolsService, IToolAndToolSetEnablementMap } from '../tools/languageModelToolsService.js'; import { ChatSessionOperationLog } from '../model/chatSessionOperationLog.js'; import { IPromptsService } from '../promptSyntax/service/promptsService.js'; import { AGENT_DEBUG_LOG_FILE_LOGGING_ENABLED_SETTING, TROUBLESHOOT_COMMAND_NAME, TROUBLESHOOT_SKILL_PATH, COPILOT_SKILL_URI_SCHEME } from '../promptSyntax/promptTypes.js'; @@ -117,6 +118,9 @@ class CancellableRequest implements IDisposable { } } +const EMPTY_REFERENCES: ReadonlyArray = Object.freeze([]); +const EMPTY_TOOL_ENABLEMENT_MAP: IToolAndToolSetEnablementMap = new Map(); + export class ChatService extends Disposable implements IChatService { declare _serviceBrand: undefined; @@ -655,6 +659,36 @@ export class ChatService extends Disposable implements IChatService { providedSession.dispose(); })); + const isAgentHostSession = isAgentHostTarget(chatSessionType); + const requestParser = isAgentHostSession ? this.instantiationService.createInstance(ChatRequestParser) : undefined; + const parseAgentHostHistoryPrompt = (text: string, agent: IChatAgentData | undefined): IParsedChatRequest => { + if (requestParser) { + try { + const sessionCapabilities = this.chatSessionService.getCapabilitiesForSessionType(chatSessionType); + const parsed = requestParser.parseChatRequestWithReferences( + EMPTY_REFERENCES, + EMPTY_TOOL_ENABLEMENT_MAP, + text, + location, + { sessionType: chatSessionType, forcedAgent: agent, attachmentCapabilities: sessionCapabilities ?? agent?.capabilities }, + ); + if (parsed.parts.length > 0) { + return parsed; + } + } catch (e) { + this.logService.warn(`ChatService#loadRemoteSession: failed to re-parse historical prompt for ${chatSessionType}`, e); + } + } + return { + text, + parts: [new ChatRequestTextPart( + new OffsetRange(0, text.length), + { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: text.length + 1 }, + text + )] + }; + }; + let lastRequest: ChatRequestModel | undefined; for (const message of providedSession.history) { if (message.type === 'request') { @@ -663,19 +697,11 @@ export class ChatService extends Disposable implements IChatService { } const requestText = message.prompt; - - const parsedRequest: IParsedChatRequest = { - text: requestText, - parts: [new ChatRequestTextPart( - new OffsetRange(0, requestText.length), - { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: requestText.length + 1 }, - requestText - )] - }; const agent = message.participant ? this.chatAgentService.getAgent(message.participant) // TODO(jospicer): Remove and always hardcode? : this.chatAgentService.getAgent(chatSessionType); + const parsedRequest = parseAgentHostHistoryPrompt(requestText, agent); const modeInfo = message.modeInstructions ? { kind: ChatModeKind.Agent, isBuiltin: message.modeInstructions.isBuiltin ?? false, @@ -754,16 +780,8 @@ export class ChatService extends Disposable implements IChatService { } // Create a new request in the model - const requestText = prompt; - const parsedRequest: IParsedChatRequest = { - text: requestText, - parts: [new ChatRequestTextPart( - new OffsetRange(0, requestText.length), - { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: requestText.length + 1 }, - requestText - )] - }; const agent = this.chatAgentService.getAgent(chatSessionType); + const parsedRequest = parseAgentHostHistoryPrompt(prompt, agent); lastRequest = model.addRequest(parsedRequest, { variables: [] }, 0, undefined, agent); // Reset progress tracking for the new turn diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 615ad4d9893a4..6fb1103b59044 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -182,6 +182,21 @@ export namespace SessionType { export const AgentHostCopilot = 'agent-host-copilot'; } +/** + * Returns whether the given session type is an agent host target. + * Matches the local agent host (`agent-host-*`) and remote agent hosts (`remote-*`). + * + * Note: The `remote-` prefix convention is established by + * `RemoteAgentHostContribution` which generates session types as + * `remote-{sanitizedAddress}-{provider}`. If future remote providers that + * are NOT agent hosts need a different prefix, this function must be updated. + */ +export function isAgentHostTarget(target: string): boolean { + return target === SessionType.AgentHostCopilot || + target.startsWith('agent-host-') || + target.startsWith('remote-'); +} + /** * The session type used for local agent chat sessions. */ diff --git a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts index 962c3e3af88f8..493269e2ab90d 100644 --- a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts @@ -20,6 +20,7 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { SessionType } from './chatSessionsService.js'; import { CustomAgent } from './promptSyntax/service/promptsServiceImpl.js'; import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js'; +import { getCanonicalPluginCommandId } from './plugins/agentPluginService.js'; export const ICustomizationHarnessService = createDecorator('customizationHarnessService'); @@ -164,8 +165,6 @@ export interface ICustomizationItem { readonly description?: string; /** Storage origin (local, user, extension, plugin). Set by providers that know the source. */ readonly storage?: PromptsStorage; - /** Display name of the contributing extension (e.g. "GitHub Copilot Chat"). */ - readonly extensionLabel?: string; /** The extension identifier that contributed this customization, if any. */ readonly extensionId: string | undefined; /** The URI of the plugin that contributed this customization, if any. */ @@ -182,6 +181,11 @@ export interface ICustomizationItem { readonly badge?: string; /** Tooltip shown when hovering the badge. */ readonly badgeTooltip?: string; + /** + * Whether this customization item can be invoked by the user. + * Relevant for prompt / skill and custom agents + */ + readonly userInvocable?: boolean; /** Optional inline/context-menu actions specific to this item. */ readonly actions?: readonly ICustomizationItemAction[]; } @@ -644,9 +648,9 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer result.push({ uri: item.uri, type: item.type as PromptsType.prompt | PromptsType.skill, - name: item.name, + name: item.pluginUri ? getCanonicalPluginCommandId({ uri: item.pluginUri }, item.name) : item.name, description: item.description, - userInvocable: true, // todo we need a way for providers to specify this if some items aren't user-invocable` + userInvocable: item.userInvocable ?? true, storage: item.storage ?? PromptsStorage.local, sessionTypes: [sessionType], }); @@ -697,23 +701,13 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer } public async resolvePromptSlashCommand(name: string, sessionType: string, token: CancellationToken): Promise { - const harness = this.findHarnessById(sessionType); - if (!harness || !harness.itemProvider) { - return this.promptsService.resolvePromptSlashCommand(name, sessionType, token); - } - - const items = await harness.itemProvider.provideChatSessionCustomizations(token); - const item = items?.find(cmd => cmd.name === name); - if (item) { - const parsedPromptFile = await this.promptsService.parseNew(item.uri, token); + const commands = await this.getSlashCommands(sessionType, token); + const command = commands.find(cmd => cmd.name === name); + if (command) { + const parsedPromptFile = await this.promptsService.parseNew(command.uri, token); return { - uri: item.uri, - type: item.type as PromptsType.prompt | PromptsType.skill, - name: item.name, - description: item.description, - userInvocable: parsedPromptFile.header?.userInvocable ?? true, - storage: item.storage ?? PromptsStorage.local, - sessionTypes: [sessionType], + ...command, + userInvocable: parsedPromptFile.header?.userInvocable ?? command.userInvocable, parsedPromptFile, }; } diff --git a/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatQuestionCarouselData.ts b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatQuestionCarouselData.ts index 656784ee3bd4c..d41cb97237dad 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatQuestionCarouselData.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatQuestionCarouselData.ts @@ -25,6 +25,22 @@ export class ChatQuestionCarouselData implements IChatQuestionCarousel { */ public dismissedByTerminalInput?: boolean; + /** + * Marks the carousel as dismissed with the given answers and clears draft + * state. Safe to call multiple times — subsequent calls are no-ops. + */ + dismiss(answers: IChatQuestionAnswers | undefined): void { + if (this.isUsed) { + return; + } + this.data = answers ?? {}; + this.isUsed = true; + this.draftAnswers = undefined; + this.draftCurrentIndex = undefined; + this.draftCollapsed = undefined; + void this.completion.complete({ answers }); + } + constructor( public questions: IChatQuestion[], public allowSkip: boolean, diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts index b6efa0a30815f..8838e19c65bd8 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts @@ -226,40 +226,22 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { if (event.resolveId !== carousel.resolveId || carousel.isUsed) { return; } - - carousel.data = event.answers ?? {}; - carousel.isUsed = true; - carousel.draftAnswers = undefined; - carousel.draftCurrentIndex = undefined; - carousel.draftCollapsed = undefined; - void carousel.completion.complete({ answers: event.answers }); + carousel.dismiss(event.answers); }); let answerResult: { answers: IChatQuestionAnswers | undefined } | undefined; try { answerResult = await raceCancellation(carousel.completion.p, token); } catch (error) { - if (error instanceof CancellationError && !carousel.isUsed) { - carousel.data = {}; - carousel.isUsed = true; - carousel.draftAnswers = undefined; - carousel.draftCurrentIndex = undefined; - carousel.draftCollapsed = undefined; - await carousel.completion.complete({ answers: undefined }); + if (error instanceof CancellationError) { + carousel.dismiss(undefined); } throw error; } finally { externalAnswerListener.dispose(); } if (!answerResult) { - if (!carousel.isUsed) { - carousel.data = {}; - carousel.isUsed = true; - carousel.draftAnswers = undefined; - carousel.draftCurrentIndex = undefined; - carousel.draftCollapsed = undefined; - await carousel.completion.complete({ answers: undefined }); - } + carousel.dismiss(undefined); throw new CancellationError(); } if (token.isCancellationRequested) { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index d1c75912a670a..f6b1771729b52 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -392,11 +392,15 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv instantiationService.stub(IChatEditingService, { registerEditingSessionProvider: () => toDisposable(() => { }), }); - instantiationService.stub(IChatService, { + const chatService = { getSession: () => undefined, onDidCreateModel: Event.None, - removePendingRequest: () => { }, - }); + removePendingRequestCalls: [] as { sessionResource: URI; requestId: string }[], + removePendingRequest(sessionResource: URI, requestId: string) { + this.removePendingRequestCalls.push({ sessionResource, requestId }); + }, + }; + instantiationService.stub(IChatService, chatService); instantiationService.stub(IAgentHostFileSystemService, { registerAuthority: () => toDisposable(() => { }), ensureSyncedCustomizationProvider: () => { }, @@ -436,11 +440,11 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv }); instantiationService.stub(IWorkbenchEnvironmentService, { isSessionsWindow: false } as Partial); - return { instantiationService, agentHostService, chatAgentService, chatWidgetService }; + return { instantiationService, agentHostService, chatAgentService, chatWidgetService, chatService }; } function createContribution(disposables: DisposableStore, opts?: { authServiceOverride?: Partial; workingDirectoryResolver?: { resolve(sessionResource: URI): URI | undefined } }) { - const { instantiationService, agentHostService, chatAgentService, chatWidgetService } = createTestServices(disposables, opts?.workingDirectoryResolver, opts?.authServiceOverride); + const { instantiationService, agentHostService, chatAgentService, chatWidgetService, chatService } = createTestServices(disposables, opts?.workingDirectoryResolver, opts?.authServiceOverride); const listController = disposables.add(instantiationService.createInstance(AgentHostSessionListController, 'agent-host-copilot', 'copilot', agentHostService, undefined, 'local')); const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { @@ -454,7 +458,7 @@ function createContribution(disposables: DisposableStore, opts?: { authServiceOv })); const contribution = disposables.add(instantiationService.createInstance(AgentHostContribution)); - return { contribution, listController, sessionHandler, agentHostService, chatAgentService, chatWidgetService }; + return { contribution, listController, sessionHandler, agentHostService, chatAgentService, chatWidgetService, chatService }; } function makeRequest(overrides: Partial<{ message: string; sessionResource: URI; variables: IChatAgentRequest['variables']; userSelectedModelId: string; modelConfiguration: Record; agentHostSessionConfig: Record; agentId: string }> = {}): IChatAgentRequest { @@ -946,6 +950,17 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(totalContent, 'hello world'); })); + test('live turn marks chat session complete after turnComplete', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); + + const { turnPromise, chatSession, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); + + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); + await turnPromise; + + assert.strictEqual(chatSession.isCompleteObs?.get(), true, 'should be complete after turn finishes'); + })); + test('tool_start events become toolInvocation progress', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); @@ -1298,6 +1313,45 @@ suite('AgentHostChatContribution', () => { // Cancellation now dispatches session/turnCancelled action assert.ok(agentHostService.dispatchedActions.some(a => a.action.type === 'session/turnCancelled')); })); + + test('cancellation marks chat session complete', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); + + const cts = new CancellationTokenSource(); + disposables.add(cts); + + const { turnPromise, chatSession } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + cancellationToken: cts.token, + }); + + cts.cancel(); + await turnPromise; + + assert.strictEqual(chatSession.isCompleteObs?.get(), true, 'chat session should be marked complete after cancellation'); + })); + + test('cancellation after natural completion does not dispatch turnCancelled', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); + + const cts = new CancellationTokenSource(); + disposables.add(cts); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + cancellationToken: cts.token, + }); + + // Turn completes naturally on its own. + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); + await turnPromise; + + // Now the request's cancellation token fires (e.g. ChatService + // cancelling a long-disposed token, or a stale 'stop' click). We + // must NOT dispatch turnCancelled for an already-finished turn. + const beforeCancelCount = agentHostService.dispatchedActions.filter(a => a.action.type === 'session/turnCancelled').length; + cts.cancel(); + const afterCancelCount = agentHostService.dispatchedActions.filter(a => a.action.type === 'session/turnCancelled').length; + assert.strictEqual(afterCancelCount, beforeCancelCount, 'turnCancelled should not be dispatched after natural completion'); + })); }); // ---- Error events ------------------------------------------------------- @@ -1452,6 +1506,158 @@ suite('AgentHostChatContribution', () => { fire({ type: 'session/turnComplete', session, turnId } as SessionAction); await turnPromise; })); + + test('local confirmation does not race with pending tc.status: no spurious re-confirm before server echo', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + // Regression for a bug where the per-tool-call autorun read both + // `part$` AND `invocation.state` and used a state-comparison check + // to detect Running → PendingConfirmation re-confirmation. After + // the user locally confirmed, `invocation.state` flipped to + // `Executing` while `tc.status` was still `PendingConfirmation` + // (server hadn't echoed yet), and the autorun spuriously emitted + // a third confirmation invocation and dispatched a duplicate + // `session/toolCallConfirmed`. The fix detects re-confirmation + // from a `tc.status` *transition*, not from invocation-state + // comparison. + // + // Baseline (bug-free) flow for a tool needing initial confirmation: + // toolCallStart → emit placeholder invocation (count=1) + // toolCallReady → status: Streaming → PendingConfirmation, + // settle placeholder, emit confirm invocation (count=2) + // user confirms → invocation.state: WaitingForConfirmation → Executing + // (count must NOT change — this is the regression) + // server echoes → tc.status: PendingConfirmation → Running (count=2) + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); + + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); + + fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-race', toolName: 'shell', displayName: 'Shell' } as SessionAction); + fire({ + type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-race', + invocationMessage: 'echo hi', toolInput: 'echo hi', + } as SessionAction); + await timeout(10); + + const beforeConfirm = collected.flat().filter(p => p.kind === 'toolInvocation') as IChatToolInvocation[]; + const permInvocation = beforeConfirm[beforeConfirm.length - 1]; + assert.strictEqual(permInvocation.state.get().type, IChatToolInvocation.StateKind.WaitingForConfirmation); + + // User confirms locally. This synchronously flips the invocation + // state from WaitingForConfirmation → Executing. The buggy + // autorun would re-fire here (because invocation.state was a + // dependency) and, finding tc.status still PendingConfirmation, + // spuriously emit yet another confirmation invocation. + IChatToolInvocation.confirmWith(permInvocation, { type: ToolConfirmKind.UserAction }); + await timeout(10); + + const afterLocalConfirm = collected.flat().filter(p => p.kind === 'toolInvocation') as IChatToolInvocation[]; + assert.strictEqual(afterLocalConfirm.length, beforeConfirm.length, 'no spurious invocation should be emitted by local confirm before server echoes'); + + // Exactly one toolCallConfirmed dispatch (with approved: true). + const confirmedDispatches = agentHostService.dispatchedActions.filter(a => { + if (a.action.type !== 'session/toolCallConfirmed') { + return false; + } + const action = a.action as IToolCallConfirmedAction; + return action.toolCallId === 'tc-race'; + }); + assert.strictEqual(confirmedDispatches.length, 1, 'exactly one session/toolCallConfirmed should be dispatched'); + assert.strictEqual((confirmedDispatches[0].action as IToolCallConfirmedAction).approved, true); + + // Echo the confirmation so the reducer transitions tc → Running, + // then complete the turn cleanly. + agentHostService.fireAction({ + action: confirmedDispatches[0].action, + serverSeq: 100, + origin: { clientId: agentHostService.clientId, clientSeq: confirmedDispatches[0].clientSeq }, + }); + fire({ + type: 'session/toolCallComplete', session, turnId, toolCallId: 'tc-race', + result: { success: true, pastTenseMessage: 'Ran echo hi', content: [{ type: 'text', text: 'hi\n' }] }, + } as SessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); + await turnPromise; + + // Final invariant: still the same number of invocations as right + // after toolCallReady — no extra invocations from the server echo + // or completion either. + const finalInvocations = collected.flat().filter(p => p.kind === 'toolInvocation'); + assert.strictEqual(finalInvocations.length, beforeConfirm.length, 'no extra invocations across the full turn'); + })); + + test('genuine re-confirmation (Running → PendingConfirmation) emits a fresh confirmation invocation', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + // Companion to the regression test above: the *legitimate* case + // where the server bounces a tool call back to PendingConfirmation + // (e.g. result confirmation after an edit). Here we DO want a + // fresh invocation and a second `session/toolCallConfirmed` + // dispatch. + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); + + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); + + fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-recon', toolName: 'shell', displayName: 'Shell' } as SessionAction); + fire({ + type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-recon', + invocationMessage: 'echo hi', toolInput: 'echo hi', + } as SessionAction); + await timeout(10); + + const firstInvocation = (collected.flat().filter(p => p.kind === 'toolInvocation') as IChatToolInvocation[]).pop()!; + assert.strictEqual(firstInvocation.state.get().type, IChatToolInvocation.StateKind.WaitingForConfirmation); + + IChatToolInvocation.confirmWith(firstInvocation, { type: ToolConfirmKind.UserAction }); + await timeout(10); + + // Echo the confirmation so tc transitions PendingConfirmation → Running. + const firstConfirm = agentHostService.dispatchedActions.find(a => { + if (a.action.type !== 'session/toolCallConfirmed') { + return false; + } + return (a.action as IToolCallConfirmedAction).toolCallId === 'tc-recon'; + })!; + agentHostService.fireAction({ + action: firstConfirm.action, + serverSeq: 100, + origin: { clientId: agentHostService.clientId, clientSeq: firstConfirm.clientSeq }, + }); + await timeout(10); + + const invocationCountAfterRunning = collected.flat().filter(p => p.kind === 'toolInvocation').length; + + // Server bounces the call back to PendingConfirmation via a + // second `toolCallReady` without `confirmed`. The reducer + // transitions Running → PendingConfirmation. + fire({ + type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-recon', + invocationMessage: 'Confirm execution', toolInput: 'echo hi', + } as SessionAction); + await timeout(10); + + // We now expect a *fresh* invocation in WaitingForConfirmation. + const invocationsAfterReconfirm = collected.flat().filter(p => p.kind === 'toolInvocation') as IChatToolInvocation[]; + assert.strictEqual(invocationsAfterReconfirm.length, invocationCountAfterRunning + 1, 'a fresh invocation should be emitted on Running → PendingConfirmation transition'); + const reconfirmInvocation = invocationsAfterReconfirm[invocationsAfterReconfirm.length - 1]; + assert.strictEqual(reconfirmInvocation.state.get().type, IChatToolInvocation.StateKind.WaitingForConfirmation); + assert.notStrictEqual(reconfirmInvocation, firstInvocation); + + // User confirms the re-confirmation; expect a second toolCallConfirmed dispatch. + IChatToolInvocation.confirmWith(reconfirmInvocation, { type: ToolConfirmKind.UserAction }); + await timeout(10); + + const allConfirms = agentHostService.dispatchedActions.filter(a => { + if (a.action.type !== 'session/toolCallConfirmed') { + return false; + } + return (a.action as IToolCallConfirmedAction).toolCallId === 'tc-recon'; + }); + assert.strictEqual(allConfirms.length, 2, 'two toolCallConfirmed dispatches expected (initial + reconfirmation)'); + + fire({ + type: 'session/toolCallComplete', session, turnId, toolCallId: 'tc-recon', + result: { success: true, pastTenseMessage: 'Done', content: [{ type: 'text', text: 'hi\n' }] }, + } as SessionAction); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); + await turnPromise; + })); }); // ---- History loading --------------------------------------------------- @@ -2967,6 +3173,92 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(chatSession.isCompleteObs!.get(), true); })); + + test('removes consumed queued message from chat model when server-initiated turn appears', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, chatService } = createContribution(disposables); + + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/untitled-queue-removal' }); + const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); + disposables.add(toDisposable(() => chatSession.dispose())); + + const registered = chatAgentService.registeredAgents.get('agent-host-copilot')!; + agentHostService.dispatchedActions.length = 0; + + // First, do a normal turn so the backend session exists. + const turn1Promise = registered.impl.invoke( + makeRequest({ message: 'Init', sessionResource }), + () => { }, [], CancellationToken.None, + ); + await timeout(10); + const dispatch1 = agentHostService.turnActions[0]; + const action1 = dispatch1.action as ITurnStartedAction; + const session = action1.session; + agentHostService.fireAction({ action: dispatch1.action, serverSeq: 1, origin: { clientId: agentHostService.clientId, clientSeq: dispatch1.clientSeq } }); + agentHostService.fireAction({ action: { type: 'session/turnComplete', session, turnId: action1.turnId } as SessionAction, serverSeq: 2, origin: undefined }); + await turn1Promise; + + // Add a queued message to the protocol state so it's tracked. + agentHostService.fireAction({ + action: { type: 'session/pendingMessageSet', session, kind: 'queued', id: 'q-1', userMessage: { text: 'will be consumed' } } as SessionAction, + serverSeq: 3, origin: undefined, + }); + await timeout(10); + + // Now the server consumes it: a server-initiated turn appears + // and the queued message disappears in the same state change. + chatService.removePendingRequestCalls.length = 0; + agentHostService.fireAction({ + action: { type: 'session/turnStarted', session, turnId: 'server-turn-q', userMessage: { text: 'will be consumed' }, queuedMessageId: 'q-1' } as SessionAction, + serverSeq: 4, origin: undefined, + }); + await timeout(10); + + // The handler should have removed the consumed queued request from the chat model. + const removedQueueIds = chatService.removePendingRequestCalls.filter(c => c.requestId === 'q-1'); + assert.strictEqual(removedQueueIds.length, 1, 'consumed queued message should be removed from chat model'); + })); + + test('removes steering message from chat model when steering id changes', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, chatService } = createContribution(disposables); + + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/untitled-steering-removal' }); + const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); + disposables.add(toDisposable(() => chatSession.dispose())); + + const registered = chatAgentService.registeredAgents.get('agent-host-copilot')!; + agentHostService.dispatchedActions.length = 0; + + // Backend session is created via a normal turn. + const turn1Promise = registered.impl.invoke( + makeRequest({ message: 'Init', sessionResource }), + () => { }, [], CancellationToken.None, + ); + await timeout(10); + const dispatch1 = agentHostService.turnActions[0]; + const action1 = dispatch1.action as ITurnStartedAction; + const session = action1.session; + agentHostService.fireAction({ action: dispatch1.action, serverSeq: 1, origin: { clientId: agentHostService.clientId, clientSeq: dispatch1.clientSeq } }); + agentHostService.fireAction({ action: { type: 'session/turnComplete', session, turnId: action1.turnId } as SessionAction, serverSeq: 2, origin: undefined }); + await turn1Promise; + + // Set a steering message on the protocol state. + agentHostService.fireAction({ + action: { type: 'session/pendingMessageSet', session, kind: 'steering', id: 'steer-1', userMessage: { text: 'be more careful' } } as SessionAction, + serverSeq: 3, origin: undefined, + }); + await timeout(10); + chatService.removePendingRequestCalls.length = 0; + + // Steering message is consumed by the agent. + agentHostService.fireAction({ + action: { type: 'session/pendingMessageRemoved', session, kind: 'steering', id: 'steer-1' } as SessionAction, + serverSeq: 4, origin: undefined, + }); + await timeout(10); + + const removed = chatService.removePendingRequestCalls.filter(c => c.requestId === 'steer-1'); + assert.strictEqual(removed.length, 1, 'previously-set steering message should be removed from chat model when it is cleared'); + })); }); // ---- Customizations dispatch ------------------------------------------ diff --git a/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts b/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts index bab36b8fe14ae..89d47ae3d2feb 100644 --- a/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts @@ -201,7 +201,7 @@ suite('CustomizationHarnessService', () => { const emitter = new Emitter(); store.add(emitter); const testItems = [ - { uri: URI.parse('file:///workspace/.claude/SKILL.md'), type: 'skill', name: 'Test Skill', description: 'A test skill', extensionId: undefined, pluginUri: undefined }, + { uri: URI.parse('file:///workspace/.claude/SKILL.md'), type: 'skill', name: 'Test Skill', description: 'A test skill', extensionId: undefined, pluginUri: undefined, userInvocable: undefined }, ]; const itemProvider: ICustomizationItemProvider = { @@ -372,10 +372,10 @@ suite('CustomizationHarnessService', () => { itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async () => [ - { uri: URI.parse('file:///workspace/.test/prompts/fix.prompt.md'), type: PromptsType.prompt, name: 'fix', description: 'Fix something', extensionId: undefined, pluginUri: undefined }, - { uri: URI.parse('file:///workspace/.test/skills/lint/SKILL.md'), type: PromptsType.skill, name: 'lint', description: 'Lint skill', extensionId: undefined, pluginUri: undefined }, - { uri: URI.parse('file:///workspace/.test/instructions/rule.instructions.md'), type: PromptsType.instructions, name: 'rule', description: 'Ignore me', extensionId: undefined, pluginUri: undefined }, - { uri: URI.parse('file:///workspace/.test/skills/disabled/SKILL.md'), type: PromptsType.skill, name: 'disabled', enabled: false, extensionId: undefined, pluginUri: undefined }, + { uri: URI.parse('file:///workspace/.test/prompts/fix.prompt.md'), type: PromptsType.prompt, name: 'fix', description: 'Fix something', extensionId: undefined, pluginUri: undefined, userInvocable: undefined }, + { uri: URI.parse('file:///workspace/.test/skills/lint/SKILL.md'), type: PromptsType.skill, name: 'lint', description: 'Lint skill', extensionId: undefined, pluginUri: undefined, userInvocable: undefined }, + { uri: URI.parse('file:///workspace/.test/instructions/rule.instructions.md'), type: PromptsType.instructions, name: 'rule', description: 'Ignore me', extensionId: undefined, pluginUri: undefined, userInvocable: undefined }, + { uri: URI.parse('file:///workspace/.test/skills/disabled/SKILL.md'), type: PromptsType.skill, name: 'disabled', enabled: false, extensionId: undefined, pluginUri: undefined, userInvocable: undefined }, ], }, }); @@ -461,8 +461,8 @@ suite('CustomizationHarnessService', () => { itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async () => [ - { uri: URI.parse('file:///workspace/.test/agents/selected.agent.md'), type: PromptsType.agent, name: 'selected', extensionId: undefined, pluginUri: undefined }, - { uri: URI.parse('file:///workspace/.test/agents/disabled.agent.md'), type: PromptsType.agent, name: 'disabled', enabled: false, extensionId: undefined, pluginUri: undefined }, + { uri: URI.parse('file:///workspace/.test/agents/selected.agent.md'), type: PromptsType.agent, name: 'selected', extensionId: undefined, pluginUri: undefined, userInvocable: undefined }, + { uri: URI.parse('file:///workspace/.test/agents/disabled.agent.md'), type: PromptsType.agent, name: 'disabled', enabled: false, extensionId: undefined, pluginUri: undefined, userInvocable: undefined }, ], }, }], testSessionType, promptsService); diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatQuestionCarouselData.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatQuestionCarouselData.test.ts index 5707ebbe4ab63..aa2a42de9c440 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/chatQuestionCarouselData.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/chatQuestionCarouselData.test.ts @@ -134,4 +134,47 @@ suite('ChatQuestionCarouselData', () => { }); }); }); + + suite('dismiss', () => { + test('sets data, marks used, clears drafts, and resolves completion', async () => { + const carousel = new ChatQuestionCarouselData(createQuestions(), true, 'resolve-1'); + carousel.draftAnswers = { q1: 'draft' }; + carousel.draftCurrentIndex = 1; + carousel.draftCollapsed = true; + + const answers = { q1: 'answer1' }; + carousel.dismiss(answers); + + assert.deepStrictEqual(carousel.data, answers); + assert.strictEqual(carousel.isUsed, true); + assert.strictEqual(carousel.draftAnswers, undefined); + assert.strictEqual(carousel.draftCurrentIndex, undefined); + assert.strictEqual(carousel.draftCollapsed, undefined); + + const result = await carousel.completion.p; + assert.deepStrictEqual(result, { answers }); + }); + + test('with undefined answers stores empty object as data', async () => { + const carousel = new ChatQuestionCarouselData(createQuestions(), true, 'resolve-1'); + carousel.dismiss(undefined); + + assert.deepStrictEqual(carousel.data, {}); + assert.strictEqual(carousel.isUsed, true); + + const result = await carousel.completion.p; + assert.strictEqual(result.answers, undefined); + }); + + test('is a no-op when already dismissed', async () => { + const carousel = new ChatQuestionCarouselData(createQuestions(), true, 'resolve-1'); + + carousel.dismiss({ q1: 'first' }); + carousel.dismiss({ q1: 'second' }); + + assert.deepStrictEqual(carousel.data, { q1: 'first' }, 'Second dismiss should not overwrite data'); + const result = await carousel.completion.p; + assert.deepStrictEqual(result.answers, { q1: 'first' }); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_agent_host__forcedAgent___supportsPromptAttachments_revives__skill_as_prompt_slash_part.0.snap b/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_agent_host__forcedAgent___supportsPromptAttachments_revives__skill_as_prompt_slash_part.0.snap new file mode 100644 index 0000000000000..71ca4a4d13b44 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_agent_host__forcedAgent___supportsPromptAttachments_revives__skill_as_prompt_slash_part.0.snap @@ -0,0 +1,33 @@ +{ + parts: [ + { + range: { + start: 0, + endExclusive: 6 + }, + editorRange: { + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 7 + }, + name: "skill", + kind: "prompt" + }, + { + range: { + start: 6, + endExclusive: 28 + }, + editorRange: { + startLineNumber: 1, + startColumn: 7, + endLineNumber: 1, + endColumn: 29 + }, + text: " plan run a quick plan", + kind: "text" + } + ], + text: "/skill plan run a quick plan" +} \ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_agent_host__missing_forcedAgent_still_revives__skill_via_no-agent_branch.0.snap b/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_agent_host__missing_forcedAgent_still_revives__skill_via_no-agent_branch.0.snap new file mode 100644 index 0000000000000..71ca4a4d13b44 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_agent_host__missing_forcedAgent_still_revives__skill_via_no-agent_branch.0.snap @@ -0,0 +1,33 @@ +{ + parts: [ + { + range: { + start: 0, + endExclusive: 6 + }, + editorRange: { + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 7 + }, + name: "skill", + kind: "prompt" + }, + { + range: { + start: 6, + endExclusive: 28 + }, + editorRange: { + startLineNumber: 1, + startColumn: 7, + endLineNumber: 1, + endColumn: 29 + }, + text: " plan run a quick plan", + kind: "text" + } + ], + text: "/skill plan run a quick plan" +} \ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/test/common/requestParser/chatRequestParser.test.ts b/src/vs/workbench/contrib/chat/test/common/requestParser/chatRequestParser.test.ts index fb7b45dab7c4c..d9996129cbaf8 100644 --- a/src/vs/workbench/contrib/chat/test/common/requestParser/chatRequestParser.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/requestParser/chatRequestParser.test.ts @@ -227,6 +227,51 @@ suite('ChatRequestParser', () => { return { id: 'agent', name: 'agent', extensionId: nullExtensionDescription.identifier, extensionVersion: undefined, publisherDisplayName: '', extensionDisplayName: '', extensionPublisherId: '', locations: [ChatAgentLocation.Chat], modes: [ChatModeKind.Ask], metadata: {}, slashCommands, disambiguation: [] } satisfies IChatAgentData; }; + test('agent host: forcedAgent + supportsPromptAttachments revives /skill as prompt slash part', async () => { + // Mirrors what AgentHostSessionHandler._parsePromptForHistory does + // when restoring a session: pass forcedAgent + capabilities + an + // empty references/tools map and expect a ChatRequestSlashPromptPart + // for /skill . + const slashCommandService = mockObject()({ _serviceBrand: undefined }); + slashCommandService.getCommands.returns([]); + instantiationService.stub(IChatSlashCommandService, slashCommandService); + + const promptsService = mockObject()({ _serviceBrand: undefined }); + promptsService.isValidSlashCommandName.callsFake((command: string) => command === 'skill'); + instantiationService.stub(IPromptsService, promptsService); + + parser = instantiationService.createInstance(ChatRequestParser); + const forcedAgent = { ...getAgentWithSlashCommands([]), capabilities: { supportsPromptAttachments: true } } satisfies IChatAgentData; + const result = parser.parseChatRequestWithReferences( + [], + new Map(), + '/skill plan run a quick plan', + ChatAgentLocation.Chat, + { sessionType: 'agent-host-copilot', forcedAgent, attachmentCapabilities: forcedAgent.capabilities }, + ); + await assertSnapshot(result); + }); + + test('agent host: missing forcedAgent still revives /skill via no-agent branch', async () => { + const slashCommandService = mockObject()({ _serviceBrand: undefined }); + slashCommandService.getCommands.returns([]); + instantiationService.stub(IChatSlashCommandService, slashCommandService); + + const promptsService = mockObject()({ _serviceBrand: undefined }); + promptsService.isValidSlashCommandName.callsFake((command: string) => command === 'skill'); + instantiationService.stub(IPromptsService, promptsService); + + parser = instantiationService.createInstance(ChatRequestParser); + const result = parser.parseChatRequestWithReferences( + [], + new Map(), + '/skill plan run a quick plan', + ChatAgentLocation.Chat, + { sessionType: 'agent-host-copilot' }, + ); + await assertSnapshot(result); + }); + test('agent with subcommand after text', async () => { const agentsService = mockObject()({ _serviceBrand: undefined, hasToolsAgent: false, onDidChangeAgents: Event.None }); agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts index d5825465a5719..057e8a1e56747 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts @@ -87,9 +87,28 @@ export class BasicExecuteStrategy extends Disposable implements ITerminalExecute this._register(store); try { + // If the terminal is already disposed or its pty has already exited + // (e.g. the shell from a previous command died before this one was + // requested), Event.toPromise(onExit/onDisposed) will subscribe to an + // emitter that has already fired and never resolves, hanging the + // run-in-terminal tool until the agent's outer timeout. Detect this + // up front and resolve immediately with the captured exit code. + if (this._instance.isDisposed) { + this._log('Terminal already disposed at strategy entry'); + throw new Error('The terminal was closed'); + } + if (this._instance.exitCode !== undefined) { + this._log(`Terminal pty already exited at strategy entry (code=${this._instance.exitCode})`); + return { + output: undefined, + exitCode: this._instance.exitCode, + additionalInformation: `Command exited with code ${this._instance.exitCode}`, + }; + } + const idlePollInterval = this._configurationService.getValue(TerminalChatAgentToolsSettingId.IdlePollInterval) ?? 1000; - const idlePromptPromise = trackIdleOnPrompt(this._instance, idlePollInterval, store, idlePollInterval); + const idlePromptPromise = trackIdleOnPrompt(this._instance, idlePollInterval, store, idlePollInterval, this._logService); const onDone = Promise.race([ Event.toPromise(this._commandDetection.onCommandFinished, store).then(e => { // When shell integration is basic, it means that the end execution event is @@ -117,7 +136,7 @@ export class BasicExecuteStrategy extends Disposable implements ITerminalExecute }), // A longer idle prompt event is used here as a catch all for unexpected cases where // the end event doesn't fire for some reason. - trackIdleOnPrompt(this._instance, idlePollInterval * 3, store, idlePollInterval).then(() => { + trackIdleOnPrompt(this._instance, idlePollInterval * 3, store, idlePollInterval, this._logService).then(() => { this._log('onDone long idle prompt'); }), ]); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts index 8f7950e28d38c..a999bd6c23ebe 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts @@ -7,6 +7,7 @@ import { DeferredPromise, RunOnceScheduler } from '../../../../../../base/common import type { CancellationToken } from '../../../../../../base/common/cancellation.js'; import type { Event } from '../../../../../../base/common/event.js'; import { DisposableStore, type IDisposable } from '../../../../../../base/common/lifecycle.js'; +import type { ITerminalLogService } from '../../../../../../platform/terminal/common/terminal.js'; import type { ITerminalInstance } from '../../../../terminal/browser/terminal.js'; import type { IMarker as IXtermMarker } from '@xterm/xterm'; @@ -164,13 +165,39 @@ export async function trackIdleOnPrompt( idleDurationMs: number, store: DisposableStore, promptFallbackMs?: number, + logService?: ITerminalLogService, ): Promise { const idleOnPrompt = new DeferredPromise(); const onData = instance.onData; + const log = logService ? (msg: string) => logService.info(`trackIdleOnPrompt: ${msg}`) : undefined; + + const enum TerminalState { + Initial, + Prompt, + Executing, + PromptAfterExecuting, + } + const stateNames: Record = { + [TerminalState.Initial]: 'Initial', + [TerminalState.Prompt]: 'Prompt', + [TerminalState.Executing]: 'Executing', + [TerminalState.PromptAfterExecuting]: 'PromptAfterExecuting', + }; + + let state: TerminalState = TerminalState.Initial; + let dataEventCount = 0; + + function setState(newState: TerminalState, reason: string): void { + if (state !== newState) { + log?.(`State ${stateNames[state]} → ${stateNames[newState]} (${reason})`); + state = newState; + } + } + const scheduler = store.add(new RunOnceScheduler(() => { + log?.(`Idle scheduler fired, completing (dataEvents=${dataEventCount})`); idleOnPrompt.complete(); }, idleDurationMs)); - let state: TerminalState = TerminalState.Initial; // Fallback in case prompt sequences are not seen but the terminal goes idle. const promptFallbackScheduler = store.add(new RunOnceScheduler(() => { @@ -178,7 +205,8 @@ export async function trackIdleOnPrompt( promptFallbackScheduler.cancel(); return; } - state = TerminalState.PromptAfterExecuting; + log?.(`Prompt fallback fired (dataEvents=${dataEventCount})`); + setState(TerminalState.PromptAfterExecuting, 'promptFallback'); scheduler.schedule(); }, promptFallbackMs ?? 1000)); // Schedule an initial fallback with a longer timeout so we can detect idle @@ -191,9 +219,11 @@ export async function trackIdleOnPrompt( // with the shorter promptFallbackMs interval. const initialFallbackScheduler = store.add(new RunOnceScheduler(() => { if (state === TerminalState.Executing || state === TerminalState.PromptAfterExecuting) { + log?.(`Initial fallback fired but state is ${stateNames[state]}, skipping`); return; } - state = TerminalState.PromptAfterExecuting; + log?.(`Initial fallback fired, no data events received`); + setState(TerminalState.PromptAfterExecuting, 'initialFallback'); scheduler.schedule(); }, 10_000)); initialFallbackScheduler.schedule(); @@ -208,7 +238,8 @@ export async function trackIdleOnPrompt( // onCommandFinished in the rich strategy's race wins before this fires. const executingFallbackScheduler = store.add(new RunOnceScheduler(() => { if (state === TerminalState.Executing) { - state = TerminalState.PromptAfterExecuting; + log?.(`Executing fallback fired after 30s data-idle (dataEvents=${dataEventCount})`); + setState(TerminalState.PromptAfterExecuting, 'executingFallback'); scheduler.schedule(); } }, 30_000)); @@ -218,13 +249,8 @@ export async function trackIdleOnPrompt( // and the terminal is idle. Note that D is treated as a signal for executed since shell // integration sometimes lacks the C sequence either due to limitations in the integation or the // required hooks aren't available. - const enum TerminalState { - Initial, - Prompt, - Executing, - PromptAfterExecuting, - } store.add(onData(e => { + dataEventCount++; // Once any data arrives, cancel the initial fallback — the data-driven // promptFallbackScheduler handles rescheduling from here. initialFallbackScheduler.cancel(); @@ -234,13 +260,13 @@ export async function trackIdleOnPrompt( for (const match of matches) { if (match.groups?.type === 'A') { if (state === TerminalState.Initial) { - state = TerminalState.Prompt; + setState(TerminalState.Prompt, 'sequence A'); } else if (state === TerminalState.Executing) { - state = TerminalState.PromptAfterExecuting; + setState(TerminalState.PromptAfterExecuting, 'sequence A after executing'); executingFallbackScheduler.cancel(); } } else if (match.groups?.type === 'C' || match.groups?.type === 'D') { - state = TerminalState.Executing; + setState(TerminalState.Executing, `sequence ${match.groups?.type}`); executingFallbackScheduler.schedule(); } } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts index c6a275bb022d2..34189dd1f829d 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts @@ -71,16 +71,30 @@ export class RichExecuteStrategy extends Disposable implements ITerminalExecuteS // shared emitters like onCommandFinished are cleaned up immediately. this._register(store); try { - // Ensure xterm is available - this._log('Waiting for xterm'); - const xterm = await this._instance.xtermReadyPromise; - if (!xterm) { - throw new Error('Xterm is not available'); + // If the terminal is already disposed or its pty has already exited + // (e.g. the shell from a previous command died before this one was + // requested), Event.toPromise(onExit/onDisposed) will subscribe to an + // emitter that has already fired and never resolves, hanging the + // run-in-terminal tool until the agent's outer timeout. Detect this + // up front and resolve immediately with the captured exit code. + if (this._instance.isDisposed) { + this._log('Terminal already disposed at strategy entry'); + throw new Error('The terminal was closed'); + } + if (this._instance.exitCode !== undefined) { + this._log(`Terminal pty already exited at strategy entry (code=${this._instance.exitCode})`); + return { + output: undefined, + exitCode: this._instance.exitCode, + additionalInformation: `Command exited with code ${this._instance.exitCode}`, + }; } - const alternateBufferPromise = createAltBufferPromise(xterm, store, this._log.bind(this)); const idlePollInterval = this._configurationService.getValue(TerminalChatAgentToolsSettingId.IdlePollInterval) ?? 1000; + // Subscribe to terminal lifecycle events BEFORE any awaits so that we + // don't miss events that fire while we're waiting for xterm to be + // ready (e.g. the pty exits during xtermReadyPromise resolution). const onDone = Promise.race([ Event.toPromise(this._commandDetection.onCommandFinished, store).then(e => { this._log('onDone via end event'); @@ -100,11 +114,19 @@ export class RichExecuteStrategy extends Disposable implements ITerminalExecuteS this._log(`onDone via process exit (${formatExitCodeOrError(exitCodeOrError)})`); return { type: 'processExit', exitCodeOrError } as const; }), - trackIdleOnPrompt(this._instance, idlePollInterval, store, idlePollInterval).then(() => { + trackIdleOnPrompt(this._instance, idlePollInterval, store, idlePollInterval, this._logService).then(() => { this._log('onDone via idle prompt'); }), ]); + // Ensure xterm is available + this._log('Waiting for xterm'); + const xterm = await this._instance.xtermReadyPromise; + if (!xterm) { + throw new Error('Xterm is not available'); + } + const alternateBufferPromise = createAltBufferPromise(xterm, store, this._log.bind(this)); + const markerRecreation = setupRecreatingStartMarker( xterm, this._startMarker, diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts index 4c8a910714c35..e2fb62a501047 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts @@ -403,9 +403,13 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { } private async _handleTimeoutState(_command: string, _invocationContext: IToolInvocationContext | undefined, _extended: boolean, _token: CancellationToken): Promise { - // Stop after extended polling (2 minutes) without notifying user if (_extended) { - this._logService.info('OutputMonitor: Extended polling timeout reached after 2 minutes'); + // Extended polling (2 minutes) expired while the process was still + // running. Rather than silently cancelling, signal that input may be + // needed so the agent sees the current output and can decide how to + // proceed (e.g. answer an unrecognised interactive prompt). + this._logService.info('OutputMonitor: Extended polling timeout reached after 2 minutes, signaling potential input needed'); + this._onDidDetectInputNeeded.fire(); this._state = OutputMonitorState.Cancelled; return false; } @@ -604,6 +608,18 @@ export function detectsHighConfidenceInputPattern(cursorLine: string): boolean { /password:? +$/i, // "Press a key" or "Press any key" /press a(?:ny)? key/i, + // Interactive prompt libraries (prompts, enquirer, inquirer) prefix the prompt with + // '? ' at the start of the line and end with a distinctive chevron character + // followed by optional trailing whitespace where the cursor is awaiting input. + // Anchoring the '?' to the start of the line (after optional whitespace/ANSI + // escapes) avoids false positives from normal output that contains both a '?' + // allow-any-unicode-next-line + // and a chevron (e.g. "What happened? ›"). + // Examples: + // "? Do you want to install jsdom? " (prompts) + // "? Pick a color " (enquirer) + // allow-any-unicode-next-line + /^(?:\s|\x1b\[[0-9;]*m)*\?.*[›❯▸▶]\s*$/, ].some(e => e.test(cursorLine)); } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index 78c1253c955fa..cf91a703a0ce7 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -2218,7 +2218,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { lastInputNeededOutput = currentOutput; lastInputNeededNotificationTime = now; const inputAction = this._buildInputNeededSteeringText(chatSessionResource, termId, /*mentionTimeout*/ false); - const message = `[Terminal ${termId} notification: command is waiting for input.]\n${inputAction}\nTerminal output:\n${currentOutput}`; + const message = `[Terminal ${termId} notification: command may be waiting for input — assess the output below.]\n${inputAction}\nTerminal output:\n${currentOutput}`; this._logService.debug(`RunInTerminalTool: Input needed in background terminal ${termId}, notifying chat session`); @@ -2226,7 +2226,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { ...sendOptions, queue: ChatRequestQueueKind.Steering, isSystemInitiated: true, - systemInitiatedLabel: localize('terminalNeedsInput', "`{0}` needs input", commandName), + systemInitiatedLabel: localize('terminalAssessingOutput', "`{0}` may need input", commandName), terminalExecutionId: termId, }).catch(e => { this._logService.warn(`RunInTerminalTool: Failed to send input-needed notification for terminal ${termId}`, e); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/basicExecuteStrategy.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/basicExecuteStrategy.test.ts index 3a6dd30c17171..9f2c725519b9c 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/basicExecuteStrategy.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/basicExecuteStrategy.test.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { strictEqual } from 'assert'; +import { strictEqual, rejects } from 'assert'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { toDisposable } from '../../../../../../base/common/lifecycle.js'; @@ -67,4 +67,66 @@ suite('BasicExecuteStrategy', () => { strictEqual(result.exitCode, 1); }); + + test('returns immediately with captured exit code when pty has already exited before execute()', async () => { + // Simulates the scenario where the shell process from a previous command + // has already died, so onExit has already fired and Event.toPromise(onExit) + // would never resolve. The strategy must short-circuit using the + // instance's already-captured exitCode. + const onCommandFinishedEmitter = new Emitter<{ getOutput(): string; exitCode: number }>(); + const onExitEmitter = new Emitter(); + const instance = { + xtermReadyPromise: Promise.resolve({}), + onData: Event.None, + onDisposed: Event.None, + onExit: onExitEmitter.event, + isDisposed: false, + exitCode: 1, + sendText: () => { throw new Error('sendText should not be called when pty already exited'); }, + } as unknown as ITerminalInstance; + const commandDetection = { + onCommandFinished: onCommandFinishedEmitter.event, + } as unknown as ICommandDetectionCapability; + const strategy = store.add(new BasicExecuteStrategy( + instance, + () => false, + commandDetection, + new TestConfigurationService(), + createLogService(), + )); + + const result = await strategy.execute('Rscript /app/ars.R', CancellationToken.None); + + strictEqual(result.exitCode, 1); + strictEqual(result.output, undefined); + strictEqual(result.additionalInformation, 'Command exited with code 1'); + }); + + test('throws "The terminal was closed" when instance is already disposed before execute()', async () => { + const onCommandFinishedEmitter = new Emitter<{ getOutput(): string; exitCode: number }>(); + const instance = { + xtermReadyPromise: Promise.resolve({}), + onData: Event.None, + onDisposed: Event.None, + onExit: Event.None, + isDisposed: true, + exitCode: undefined, + sendText: () => { throw new Error('sendText should not be called when terminal is disposed'); }, + } as unknown as ITerminalInstance; + const commandDetection = { + onCommandFinished: onCommandFinishedEmitter.event, + } as unknown as ICommandDetectionCapability; + const strategy = store.add(new BasicExecuteStrategy( + instance, + () => false, + commandDetection, + new TestConfigurationService(), + createLogService(), + )); + + await rejects( + () => strategy.execute('echo hello', CancellationToken.None), + /The terminal was closed/ + ); + }); }); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts index f4fcd47d4d8c8..70b8bdfec2f18 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts @@ -233,6 +233,25 @@ suite('OutputMonitor', () => { }); }); + test('extended timeout with isActive fires onDidDetectInputNeeded', async () => { + return runWithFakedTimers({}, async () => { + // Simulate a process that stays active with output that doesn't + // match any input-required pattern — the extended timeout should + // fire onDidDetectInputNeeded so the agent can assess the output. + execution.isActive = async () => true; + execution.getOutput = () => 'Some unrecognised prompt waiting for input'; + + monitor = store.add(instantiationService.createInstance(OutputMonitor, execution, undefined, createTestContext('1'), cts.token, 'test command')); + + let inputNeededFired = false; + store.add(monitor.onDidDetectInputNeeded(() => { inputNeededFired = true; })); + + await Event.toPromise(monitor.onDidFinishCommand); + assert.strictEqual(inputNeededFired, true, 'onDidDetectInputNeeded should fire on extended timeout with active process'); + assert.strictEqual(monitor.pollingResult?.state, OutputMonitorState.Cancelled); + }); + }); + test('non-interactive help on the last line stops monitoring before custom polling', async () => { return runWithFakedTimers({}, async () => { execution.getOutput = () => 'Build complete successfully\npress h + enter to show help'; @@ -354,6 +373,44 @@ suite('OutputMonitor', () => { assert.strictEqual(detectsInputRequiredPattern('license: (ISC) '), true); }); + test('detects chevron prompts from prompts/enquirer/inquirer libraries', () => { + // vitest / npm-style "prompts" library uses U+203A SINGLE RIGHT-POINTING ANGLE QUOTATION MARK + // allow-any-unicode-next-line + assert.strictEqual(detectsInputRequiredPattern('? Do you want to install jsdom? ›'), true); + // allow-any-unicode-next-line + assert.strictEqual(detectsInputRequiredPattern('? Do you want to install jsdom? › '), true); + // inquirer / enquirer uses U+276F HEAVY RIGHT-POINTING ANGLE QUOTATION MARK + // allow-any-unicode-next-line + assert.strictEqual(detectsInputRequiredPattern('? Pick a color ❯ '), true); + // allow-any-unicode-next-line + assert.strictEqual(detectsInputRequiredPattern('? Pick a color ❯'), true); + // Other chevron variants prefixed with '?' + // allow-any-unicode-next-line + assert.strictEqual(detectsInputRequiredPattern('? Project name ▸ '), true); + // allow-any-unicode-next-line + assert.strictEqual(detectsInputRequiredPattern('? Choose ▶ '), true); + + // No match if the user has already typed a response after the chevron + // allow-any-unicode-next-line + assert.strictEqual(detectsInputRequiredPattern('? Do you want to install jsdom? › yes'), false); + // allow-any-unicode-next-line + assert.strictEqual(detectsInputRequiredPattern('? Pick a color ❯ red'), false); + + // No match for chevrons in normal output without a leading '?' + // allow-any-unicode-next-line + assert.strictEqual(detectsInputRequiredPattern(' feature/foo ❯ main'), false); + // allow-any-unicode-next-line + assert.strictEqual(detectsInputRequiredPattern('Project name ▸ '), false); + + // No match when '?' appears mid-line (not as a prompt prefix) + // allow-any-unicode-next-line + assert.strictEqual(detectsInputRequiredPattern('What happened? ›'), false); + + // Match when prompt is prefixed with ANSI escape codes (colored output) + // allow-any-unicode-next-line + assert.strictEqual(detectsInputRequiredPattern('\x1b[32m? Choose a framework \x1b[0m›'), true); + }); + test('detects trailing questions', () => { assert.strictEqual(detectsInputRequiredPattern('Continue? '), true); assert.strictEqual(detectsInputRequiredPattern('Proceed? '), true); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/richExecuteStrategy.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/richExecuteStrategy.test.ts index 159916156921b..088d809938cb4 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/richExecuteStrategy.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/richExecuteStrategy.test.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { strictEqual } from 'assert'; +import { rejects, strictEqual } from 'assert'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { toDisposable } from '../../../../../../base/common/lifecycle.js'; @@ -161,4 +161,64 @@ suite('RichExecuteStrategy', () => { strictEqual(result.exitCode, 127); }); + + test('returns immediately with captured exit code when pty has already exited before execute()', async () => { + // Simulates the scenario where the shell process from a previous command + // has already died, so onExit has already fired and Event.toPromise(onExit) + // would never resolve. The strategy must short-circuit using the + // instance's already-captured exitCode. + const onCommandFinishedEmitter = new Emitter<{ getOutput(): string; exitCode: number }>(); + const onExitEmitter = new Emitter(); + const instance = { + xtermReadyPromise: Promise.resolve({}), + onData: Event.None, + onDisposed: Event.None, + onExit: onExitEmitter.event, + isDisposed: false, + exitCode: 1, + runCommand: () => { throw new Error('runCommand should not be called when pty already exited'); }, + } as unknown as ITerminalInstance; + const commandDetection = { + onCommandFinished: onCommandFinishedEmitter.event, + } as unknown as ICommandDetectionCapability; + const strategy = store.add(new RichExecuteStrategy( + instance, + commandDetection, + new TestConfigurationService(), + createLogService(), + )); + + const result = await strategy.execute('Rscript /app/ars.R', CancellationToken.None); + + strictEqual(result.exitCode, 1); + strictEqual(result.output, undefined); + strictEqual(result.additionalInformation, 'Command exited with code 1'); + }); + + test('throws "The terminal was closed" when instance is already disposed before execute()', async () => { + const onCommandFinishedEmitter = new Emitter<{ getOutput(): string; exitCode: number }>(); + const instance = { + xtermReadyPromise: Promise.resolve({}), + onData: Event.None, + onDisposed: Event.None, + onExit: Event.None, + isDisposed: true, + exitCode: undefined, + runCommand: () => { throw new Error('runCommand should not be called when terminal is disposed'); }, + } as unknown as ITerminalInstance; + const commandDetection = { + onCommandFinished: onCommandFinishedEmitter.event, + } as unknown as ICommandDetectionCapability; + const strategy = store.add(new RichExecuteStrategy( + instance, + commandDetection, + new TestConfigurationService(), + createLogService(), + )); + + await rejects( + () => strategy.execute('echo hello', CancellationToken.None), + /The terminal was closed/ + ); + }); }); diff --git a/src/vs/workbench/contrib/terminalContrib/clipboard/browser/terminalClipboard.ts b/src/vs/workbench/contrib/terminalContrib/clipboard/browser/terminalClipboard.ts index cbcaf4c54c5fc..22af80b8499ad 100644 --- a/src/vs/workbench/contrib/terminalContrib/clipboard/browser/terminalClipboard.ts +++ b/src/vs/workbench/contrib/terminalContrib/clipboard/browser/terminalClipboard.ts @@ -50,10 +50,13 @@ export async function shouldPasteTerminalText(accessor: ServicesAccessor, text: return true; } - const textForLines = text.split(/\r?\n/); - // Ignore check when a command is copied with a trailing new line + // When a command is copied with a trailing new line, strip the trailing newline so the + // command is not automatically executed on paste. This mitigates a clipboard hijacking + // scenario where a malicious page replaces clipboard contents with a command followed by + // a newline; pasting (especially via right click) would otherwise immediately execute it. + // The user can still review the pasted text and press Enter to run it. if (textForLines.length === 2 && textForLines[1].trim().length === 0) { - return true; + return { modifiedText: textForLines[0] }; } } diff --git a/src/vs/workbench/contrib/terminalContrib/clipboard/test/browser/terminalClipboard.test.ts b/src/vs/workbench/contrib/terminalContrib/clipboard/test/browser/terminalClipboard.test.ts index 84b70193d4487..daa110353ea00 100644 --- a/src/vs/workbench/contrib/terminalContrib/clipboard/test/browser/terminalClipboard.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/clipboard/test/browser/terminalClipboard.test.ts @@ -57,7 +57,15 @@ suite('TerminalClipboard', function () { strictEqual(await instantiationService.invokeFunction(shouldPasteTerminalText, 'foo', undefined), true); }); test('Single line string with trailing new line', async () => { - strictEqual(await instantiationService.invokeFunction(shouldPasteTerminalText, 'foo\n', undefined), true); + // Auto: strip the trailing newline to avoid auto-executing the command on paste + // (mitigates clipboard hijack auto-exec via right-click paste). + strictEqual(JSON.stringify(await instantiationService.invokeFunction(shouldPasteTerminalText, 'foo\n', undefined)), JSON.stringify({ modifiedText: 'foo' })); + strictEqual(JSON.stringify(await instantiationService.invokeFunction(shouldPasteTerminalText, 'foo\n ', undefined)), JSON.stringify({ modifiedText: 'foo' })); + strictEqual(JSON.stringify(await instantiationService.invokeFunction(shouldPasteTerminalText, 'foo\n\t', undefined)), JSON.stringify({ modifiedText: 'foo' })); + // Auto with bracketed paste mode: shell handles newline literally, safe to paste as-is. + strictEqual(await instantiationService.invokeFunction(shouldPasteTerminalText, 'foo\n', true), true); + strictEqual(await instantiationService.invokeFunction(shouldPasteTerminalText, 'foo\n ', true), true); + strictEqual(await instantiationService.invokeFunction(shouldPasteTerminalText, 'foo\n\t', true), true); setConfigValue('always'); strictEqual(await instantiationService.invokeFunction(shouldPasteTerminalText, 'foo\n', undefined), false); diff --git a/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts b/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts index 340d641d63548..54345bcf55c6c 100644 --- a/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts +++ b/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts @@ -244,6 +244,22 @@ declare module 'vscode' { * Optional session types that describe when the hook should be offered. */ readonly sessionTypes?: readonly string[]; + + /** + * Where the chat resource was loaded from. + */ + readonly source: ChatResourceSource; + + /** + * The contributing extension identifier when {@link source} is `extension`. + */ + readonly extensionId?: string; + + /** + * The contributing plugin URI when {@link source} is `plugin`. + */ + readonly pluginUri?: Uri; + } export interface ChatPlugin { @@ -252,6 +268,7 @@ declare module 'vscode' { * Optional session types that describe when the plugin should be offered. */ readonly sessionTypes?: readonly string[]; + } // #endregion diff --git a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts index 33027458d2c7c..3cd229ebe8d22 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts @@ -89,6 +89,16 @@ declare module 'vscode' { */ readonly description?: string; + /** + * The extension identifier that contributed this customization, if any. + */ + readonly extensionId?: string; + + /** + * The URI of the plugin that contributed this customization, if any. + */ + readonly pluginUri?: Uri; + /** * Optional group key for display grouping. Items sharing the same * `groupKey` are placed under a shared collapsible header in the @@ -109,6 +119,12 @@ declare module 'vscode' { * Optional tooltip text shown when hovering over the badge. */ readonly badgeTooltip?: string; + + /** + * Whether this item should be shown to users as invocable. + * Applies to agents, skills, and prompts. When `false`, the item is hidden from the UI and cannot be invoked by users, + */ + readonly userInvocable?: boolean; } /**