From 336b8eeaddfcef43be86459d2afcc5ffa2d885f5 Mon Sep 17 00:00:00 2001 From: roblourens Date: Sat, 15 Aug 2026 09:29:34 -0700 Subject: [PATCH 1/8] agentHost: Increase E2E coverage (#330995) agentHost: increase E2E coverage Add stable conformance coverage for chat references, filesystem operations, and Git sync.\n\n(Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/test/node/e2e/KNOWN_ISSUES.md | 17 + .../test/node/e2e/coverage/summary.json | 1908 +++++++++-------- .../test/node/e2e/suites/changesetSuite.ts | 207 +- .../node/e2e/suites/clientFilesystemSuite.ts | 116 + .../test/node/e2e/suites/multiChatSuite.ts | 140 ++ 5 files changed, 1517 insertions(+), 871 deletions(-) diff --git a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md index b991fe3f371ce8..7d2c6dbf5e55f1 100644 --- a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md +++ b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md @@ -469,6 +469,23 @@ A capture that genuinely cannot be refreshed goes in `STALE_RECORDED_REQUEST_EXC Remove the entry from `STALE_RECORDED_REQUEST_EXCEPTIONS` and re-record once the fork defect is fixed. ## Suspected product bugs +### Resource reads ignore the requested base64 encoding + +A client can request arbitrary file bytes from the Agent Host in base64 so binary data remains lossless. The host always reports UTF-8 instead, and bytes that are not valid UTF-8 cannot be reconstructed by the client. + +- Test: `resourceRead returns requested base64 content without byte loss`. +- Scope: conformance reference provider on all platforms. +- Expected: AHP `resourceRead` honors `encoding: "base64"` and returns all requested bytes with `encoding: "base64"`. +- Observed: the response reports `encoding: "utf-8"` and stringifies the raw bytes as text. +- Gate: the scenario requires `AGENT_HOST_RUN_KNOWN_ISSUES=1`. +- Reproduce: + + ```bash + AGENT_HOST_RUN_KNOWN_ISSUES=1 ./scripts/test-integration.sh --run \ + src/vs/platform/agentHost/test/node/e2e/conformance/agentHostConformance.integrationTest.ts \ + --grep "resourceRead returns requested base64 content without byte loss" + ``` + ### Branch changeset stays stale after a second edit to the same file - Test: `a second edit updates one changeset entry in place`. diff --git a/src/vs/platform/agentHost/test/node/e2e/coverage/summary.json b/src/vs/platform/agentHost/test/node/e2e/coverage/summary.json index a60d0e8548c39a..6863ae43bd48b4 100644 --- a/src/vs/platform/agentHost/test/node/e2e/coverage/summary.json +++ b/src/vs/platform/agentHost/test/node/e2e/coverage/summary.json @@ -15,47 +15,47 @@ }, "total": { "statements": { - "covered": 83077, - "total": 109466, - "percentage": 75.89 + "covered": 86322, + "total": 113954, + "percentage": 75.75 }, "branches": { - "covered": 9300, - "total": 13964, - "percentage": 66.59 + "covered": 9917, + "total": 14872, + "percentage": 66.68 }, "functions": { - "covered": 3027, - "total": 4252, - "percentage": 71.19 + "covered": 3230, + "total": 4520, + "percentage": 71.46 }, "lines": { - "covered": 83077, - "total": 109466, - "percentage": 75.89 + "covered": 86322, + "total": 113954, + "percentage": 75.75 } }, "files": { "src/vs/platform/agentHost/common/agent.ts": { "statements": { - "covered": 1112, - "total": 1120, - "percentage": 99.28 + "covered": 1149, + "total": 1159, + "percentage": 99.13 }, "branches": { - "covered": 26, - "total": 33, - "percentage": 78.78 + "covered": 27, + "total": 35, + "percentage": 77.14 }, "functions": { - "covered": 9, - "total": 10, - "percentage": 90 + "covered": 10, + "total": 12, + "percentage": 83.33 }, "lines": { - "covered": 1112, - "total": 1120, - "percentage": 99.28 + "covered": 1149, + "total": 1159, + "percentage": 99.13 } }, "src/vs/platform/agentHost/common/agentClientUri.ts": { @@ -148,8 +148,8 @@ }, "src/vs/platform/agentHost/common/agentHostChangesetService.ts": { "statements": { - "covered": 305, - "total": 305, + "covered": 306, + "total": 306, "percentage": 100 }, "branches": { @@ -163,8 +163,8 @@ "percentage": 100 }, "lines": { - "covered": 305, - "total": 305, + "covered": 306, + "total": 306, "percentage": 100 } }, @@ -259,8 +259,8 @@ "src/vs/platform/agentHost/common/agentHostConfigurationSync.ts": { "statements": { "covered": 72, - "total": 143, - "percentage": 50.34 + "total": 148, + "percentage": 48.64 }, "branches": { "covered": 0, @@ -274,8 +274,8 @@ }, "lines": { "covered": 72, - "total": 143, - "percentage": 50.34 + "total": 148, + "percentage": 48.64 } }, "src/vs/platform/agentHost/common/agentHostConversationContext.ts": { @@ -303,8 +303,8 @@ "src/vs/platform/agentHost/common/agentHostCustomizationConfig.ts": { "statements": { "covered": 128, - "total": 142, - "percentage": 90.14 + "total": 141, + "percentage": 90.78 }, "branches": { "covered": 1, @@ -318,8 +318,8 @@ }, "lines": { "covered": 128, - "total": 142, - "percentage": 90.14 + "total": 141, + "percentage": 90.78 } }, "src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts": { @@ -373,9 +373,9 @@ "percentage": 95.08 }, "branches": { - "covered": 17, - "total": 21, - "percentage": 80.95 + "covered": 16, + "total": 20, + "percentage": 80 }, "functions": { "covered": 5, @@ -456,14 +456,14 @@ }, "src/vs/platform/agentHost/common/agentHostSchema.ts": { "statements": { - "covered": 679, - "total": 775, - "percentage": 87.61 + "covered": 711, + "total": 807, + "percentage": 88.1 }, "branches": { - "covered": 48, - "total": 67, - "percentage": 71.64 + "covered": 49, + "total": 68, + "percentage": 72.05 }, "functions": { "covered": 15, @@ -471,9 +471,9 @@ "percentage": 68.18 }, "lines": { - "covered": 679, - "total": 775, - "percentage": 87.61 + "covered": 711, + "total": 807, + "percentage": 88.1 } }, "src/vs/platform/agentHost/common/agentHostSlashCommand.ts": { @@ -500,24 +500,24 @@ }, "src/vs/platform/agentHost/common/agentHostTelemetry.ts": { "statements": { - "covered": 68, - "total": 81, - "percentage": 83.95 + "covered": 85, + "total": 106, + "percentage": 80.18 }, "branches": { - "covered": 5, - "total": 13, - "percentage": 38.46 + "covered": 8, + "total": 19, + "percentage": 42.1 }, "functions": { - "covered": 2, - "total": 4, - "percentage": 50 + "covered": 5, + "total": 7, + "percentage": 71.42 }, "lines": { - "covered": 68, - "total": 81, - "percentage": 83.95 + "covered": 85, + "total": 106, + "percentage": 80.18 } }, "src/vs/platform/agentHost/common/agentHostTelemetryEnv.ts": { @@ -610,9 +610,9 @@ }, "src/vs/platform/agentHost/common/agentModelPricing.ts": { "statements": { - "covered": 179, - "total": 282, - "percentage": 63.47 + "covered": 178, + "total": 281, + "percentage": 63.34 }, "branches": { "covered": 4, @@ -625,9 +625,9 @@ "percentage": 37.5 }, "lines": { - "covered": 179, - "total": 282, - "percentage": 63.47 + "covered": 178, + "total": 281, + "percentage": 63.34 } }, "src/vs/platform/agentHost/common/agentModelSource.ts": { @@ -676,9 +676,9 @@ }, "src/vs/platform/agentHost/common/agentService.ts": { "statements": { - "covered": 1010, - "total": 1175, - "percentage": 85.95 + "covered": 1016, + "total": 1181, + "percentage": 86.02 }, "branches": { "covered": 9, @@ -691,9 +691,9 @@ "percentage": 12.5 }, "lines": { - "covered": 1010, - "total": 1175, - "percentage": 85.95 + "covered": 1016, + "total": 1181, + "percentage": 86.02 } }, "src/vs/platform/agentHost/common/agentTelemetryCorrelation.ts": { @@ -725,9 +725,9 @@ "percentage": 80.07 }, "branches": { - "covered": 28, - "total": 38, - "percentage": 73.68 + "covered": 25, + "total": 35, + "percentage": 71.42 }, "functions": { "covered": 11, @@ -769,9 +769,9 @@ "percentage": 86.48 }, "branches": { - "covered": 49, - "total": 61, - "percentage": 80.32 + "covered": 48, + "total": 60, + "percentage": 80 }, "functions": { "covered": 19, @@ -918,24 +918,24 @@ }, "src/vs/platform/agentHost/common/copilotCliConfig.ts": { "statements": { - "covered": 159, - "total": 159, - "percentage": 100 + "covered": 214, + "total": 223, + "percentage": 95.96 }, "branches": { - "covered": 8, - "total": 15, - "percentage": 53.33 + "covered": 7, + "total": 13, + "percentage": 53.84 }, "functions": { - "covered": 3, + "covered": 2, "total": 3, - "percentage": 100 + "percentage": 66.66 }, "lines": { - "covered": 159, - "total": 159, - "percentage": 100 + "covered": 214, + "total": 223, + "percentage": 95.96 } }, "src/vs/platform/agentHost/common/copilotConfigSlashCommands.ts": { @@ -982,6 +982,28 @@ "percentage": 72.22 } }, + "src/vs/platform/agentHost/common/customizationEnablement.ts": { + "statements": { + "covered": 73, + "total": 120, + "percentage": 60.83 + }, + "branches": { + "covered": 8, + "total": 8, + "percentage": 100 + }, + "functions": { + "covered": 3, + "total": 7, + "percentage": 42.85 + }, + "lines": { + "covered": 73, + "total": 120, + "percentage": 60.83 + } + }, "src/vs/platform/agentHost/common/diffComputeService.ts": { "statements": { "covered": 53, @@ -1202,6 +1224,28 @@ "percentage": 39.68 } }, + "src/vs/platform/agentHost/common/meta/agentMessageDelegationMeta.ts": { + "statements": { + "covered": 20, + "total": 30, + "percentage": 66.66 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 2, + "percentage": 0 + }, + "lines": { + "covered": 20, + "total": 30, + "percentage": 66.66 + } + }, "src/vs/platform/agentHost/common/meta/agentSystemNotificationMeta.ts": { "statements": { "covered": 27, @@ -1248,9 +1292,9 @@ }, "src/vs/platform/agentHost/common/openSessionLink.ts": { "statements": { - "covered": 108, - "total": 124, - "percentage": 87.09 + "covered": 116, + "total": 151, + "percentage": 76.82 }, "branches": { "covered": 12, @@ -1259,13 +1303,13 @@ }, "functions": { "covered": 4, - "total": 8, - "percentage": 50 + "total": 10, + "percentage": 40 }, "lines": { - "covered": 108, - "total": 124, - "percentage": 87.09 + "covered": 116, + "total": 151, + "percentage": 76.82 } }, "src/vs/platform/agentHost/common/otel/agentHostOTelService.ts": { @@ -1424,8 +1468,8 @@ }, "src/vs/platform/agentHost/common/serverToolNames.ts": { "statements": { - "covered": 27, - "total": 27, + "covered": 28, + "total": 28, "percentage": 100 }, "branches": { @@ -1439,8 +1483,8 @@ "percentage": 100 }, "lines": { - "covered": 27, - "total": 27, + "covered": 28, + "total": 28, "percentage": 100 } }, @@ -1468,23 +1512,23 @@ }, "src/vs/platform/agentHost/common/sessionDataService.ts": { "statements": { - "covered": 466, - "total": 466, + "covered": 478, + "total": 478, "percentage": 100 }, "branches": { - "covered": 0, - "total": 0, + "covered": 1, + "total": 1, "percentage": 100 }, "functions": { - "covered": 0, - "total": 0, + "covered": 1, + "total": 1, "percentage": 100 }, "lines": { - "covered": 466, - "total": 466, + "covered": 478, + "total": 478, "percentage": 100 } }, @@ -1622,14 +1666,14 @@ }, "src/vs/platform/agentHost/common/state/protocol/channels-changeset/reducer.ts": { "statements": { - "covered": 92, + "covered": 93, "total": 121, - "percentage": 76.03 + "percentage": 76.85 }, "branches": { - "covered": 16, - "total": 26, - "percentage": 61.53 + "covered": 20, + "total": 29, + "percentage": 68.96 }, "functions": { "covered": 1, @@ -1637,9 +1681,9 @@ "percentage": 100 }, "lines": { - "covered": 92, + "covered": 93, "total": 121, - "percentage": 76.03 + "percentage": 76.85 } }, "src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts": { @@ -1710,24 +1754,24 @@ }, "src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts": { "statements": { - "covered": 284, - "total": 401, - "percentage": 70.82 + "covered": 299, + "total": 420, + "percentage": 71.19 }, "branches": { - "covered": 60, - "total": 85, - "percentage": 70.58 + "covered": 62, + "total": 89, + "percentage": 69.66 }, "functions": { - "covered": 5, - "total": 5, + "covered": 6, + "total": 6, "percentage": 100 }, "lines": { - "covered": 284, - "total": 401, - "percentage": 70.82 + "covered": 299, + "total": 420, + "percentage": 71.19 } }, "src/vs/platform/agentHost/common/state/protocol/channels-terminal/reducer.ts": { @@ -1842,8 +1886,8 @@ }, "src/vs/platform/agentHost/common/state/protocol/common/notifications.ts": { "statements": { - "covered": 63, - "total": 63, + "covered": 67, + "total": 67, "percentage": 100 }, "branches": { @@ -1857,8 +1901,8 @@ "percentage": 100 }, "lines": { - "covered": 63, - "total": 63, + "covered": 67, + "total": 67, "percentage": 100 } }, @@ -2128,24 +2172,24 @@ }, "src/vs/platform/agentHost/common/state/sessionState.ts": { "statements": { - "covered": 1370, - "total": 1829, - "percentage": 74.9 + "covered": 1401, + "total": 1848, + "percentage": 75.81 }, "branches": { - "covered": 147, - "total": 215, - "percentage": 68.37 + "covered": 160, + "total": 232, + "percentage": 68.96 }, "functions": { - "covered": 55, - "total": 83, - "percentage": 66.26 + "covered": 58, + "total": 85, + "percentage": 68.23 }, "lines": { - "covered": 1370, - "total": 1829, - "percentage": 74.9 + "covered": 1401, + "total": 1848, + "percentage": 75.81 } }, "src/vs/platform/agentHost/common/state/sessionWorkingDirectories.ts": { @@ -2282,24 +2326,24 @@ }, "src/vs/platform/agentHost/node/agentConfigurationService.ts": { "statements": { - "covered": 389, - "total": 422, - "percentage": 92.18 + "covered": 385, + "total": 420, + "percentage": 91.66 }, "branches": { - "covered": 42, - "total": 57, - "percentage": 73.68 + "covered": 44, + "total": 60, + "percentage": 73.33 }, "functions": { - "covered": 16, - "total": 17, - "percentage": 94.11 + "covered": 17, + "total": 18, + "percentage": 94.44 }, "lines": { - "covered": 389, - "total": 422, - "percentage": 92.18 + "covered": 385, + "total": 420, + "percentage": 91.66 } }, "src/vs/platform/agentHost/node/agentHostAuthenticationService.ts": { @@ -2392,14 +2436,14 @@ }, "src/vs/platform/agentHost/node/agentHostChangesetFileMonitorCoordinator.ts": { "statements": { - "covered": 381, - "total": 441, - "percentage": 86.39 + "covered": 380, + "total": 440, + "percentage": 86.36 }, "branches": { - "covered": 67, - "total": 89, - "percentage": 75.28 + "covered": 63, + "total": 85, + "percentage": 74.11 }, "functions": { "covered": 29, @@ -2407,43 +2451,43 @@ "percentage": 96.66 }, "lines": { - "covered": 381, - "total": 441, - "percentage": 86.39 + "covered": 380, + "total": 440, + "percentage": 86.36 } }, "src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts": { "statements": { - "covered": 252, + "covered": 265, "total": 296, - "percentage": 85.13 + "percentage": 89.52 }, "branches": { - "covered": 51, - "total": 66, - "percentage": 77.27 + "covered": 53, + "total": 69, + "percentage": 76.81 }, "functions": { - "covered": 12, + "covered": 13, "total": 13, - "percentage": 92.3 + "percentage": 100 }, "lines": { - "covered": 252, + "covered": 265, "total": 296, - "percentage": 85.13 + "percentage": 89.52 } }, "src/vs/platform/agentHost/node/agentHostChangesetService.ts": { "statements": { - "covered": 1226, - "total": 1645, - "percentage": 74.52 + "covered": 1229, + "total": 1646, + "percentage": 74.66 }, "branches": { - "covered": 184, + "covered": 185, "total": 258, - "percentage": 71.31 + "percentage": 71.7 }, "functions": { "covered": 56, @@ -2451,9 +2495,9 @@ "percentage": 80 }, "lines": { - "covered": 1226, - "total": 1645, - "percentage": 74.52 + "covered": 1229, + "total": 1646, + "percentage": 74.66 } }, "src/vs/platform/agentHost/node/agentHostChangesetStateCache.ts": { @@ -2502,9 +2546,9 @@ }, "src/vs/platform/agentHost/node/agentHostChangesetTelemetry.ts": { "statements": { - "covered": 148, - "total": 162, - "percentage": 91.35 + "covered": 151, + "total": 165, + "percentage": 91.51 }, "branches": { "covered": 10, @@ -2517,31 +2561,31 @@ "percentage": 66.66 }, "lines": { - "covered": 148, - "total": 162, - "percentage": 91.35 + "covered": 151, + "total": 165, + "percentage": 91.51 } }, "src/vs/platform/agentHost/node/agentHostChatCompletionProvider.ts": { "statements": { - "covered": 133, + "covered": 204, "total": 216, - "percentage": 61.57 + "percentage": 94.44 }, "branches": { - "covered": 11, - "total": 22, - "percentage": 50 + "covered": 41, + "total": 52, + "percentage": 78.84 }, "functions": { - "covered": 3, + "covered": 6, "total": 6, - "percentage": 50 + "percentage": 100 }, "lines": { - "covered": 133, + "covered": 204, "total": 216, - "percentage": 61.57 + "percentage": 94.44 } }, "src/vs/platform/agentHost/node/agentHostCheckpointService.ts": { @@ -2551,9 +2595,9 @@ "percentage": 70.86 }, "branches": { - "covered": 80, - "total": 110, - "percentage": 72.72 + "covered": 77, + "total": 107, + "percentage": 71.96 }, "functions": { "covered": 17, @@ -2654,26 +2698,48 @@ "percentage": 96.99 } }, + "src/vs/platform/agentHost/node/agentHostCustomizationEnablementService.ts": { + "statements": { + "covered": 558, + "total": 723, + "percentage": 77.17 + }, + "branches": { + "covered": 100, + "total": 143, + "percentage": 69.93 + }, + "functions": { + "covered": 41, + "total": 47, + "percentage": 87.23 + }, + "lines": { + "covered": 558, + "total": 723, + "percentage": 77.17 + } + }, "src/vs/platform/agentHost/node/agentHostDatabase.ts": { "statements": { - "covered": 267, - "total": 304, - "percentage": 87.82 + "covered": 301, + "total": 360, + "percentage": 83.61 }, "branches": { - "covered": 42, - "total": 56, + "covered": 45, + "total": 60, "percentage": 75 }, "functions": { - "covered": 23, + "covered": 22, "total": 28, - "percentage": 82.14 + "percentage": 78.57 }, "lines": { - "covered": 267, - "total": 304, - "percentage": 87.82 + "covered": 301, + "total": 360, + "percentage": 83.61 } }, "src/vs/platform/agentHost/node/agentHostDiscardChangesOperationHandler.ts": { @@ -2722,14 +2788,14 @@ }, "src/vs/platform/agentHost/node/agentHostFileCompletionProvider.ts": { "statements": { - "covered": 245, + "covered": 247, "total": 317, - "percentage": 77.28 + "percentage": 77.91 }, "branches": { - "covered": 35, + "covered": 36, "total": 57, - "percentage": 61.4 + "percentage": 63.15 }, "functions": { "covered": 8, @@ -2737,9 +2803,9 @@ "percentage": 88.88 }, "lines": { - "covered": 245, + "covered": 247, "total": 317, - "percentage": 77.28 + "percentage": 77.91 } }, "src/vs/platform/agentHost/node/agentHostFileCompletionUtils.ts": { @@ -2771,9 +2837,9 @@ "percentage": 89.18 }, "branches": { - "covered": 22, - "total": 34, - "percentage": 64.7 + "covered": 23, + "total": 35, + "percentage": 65.71 }, "functions": { "covered": 14, @@ -2832,36 +2898,36 @@ }, "src/vs/platform/agentHost/node/agentHostGitService.ts": { "statements": { - "covered": 1117, - "total": 1692, - "percentage": 66.01 + "covered": 1194, + "total": 1697, + "percentage": 70.35 }, "branches": { - "covered": 226, - "total": 323, - "percentage": 69.96 + "covered": 263, + "total": 371, + "percentage": 70.88 }, "functions": { - "covered": 55, - "total": 79, - "percentage": 69.62 + "covered": 61, + "total": 80, + "percentage": 76.25 }, "lines": { - "covered": 1117, - "total": 1692, - "percentage": 66.01 + "covered": 1194, + "total": 1697, + "percentage": 70.35 } }, "src/vs/platform/agentHost/node/agentHostGitStateService.ts": { "statements": { - "covered": 233, + "covered": 232, "total": 424, - "percentage": 54.95 + "percentage": 54.71 }, "branches": { - "covered": 68, - "total": 92, - "percentage": 73.91 + "covered": 63, + "total": 88, + "percentage": 71.59 }, "functions": { "covered": 9, @@ -2869,9 +2935,9 @@ "percentage": 64.28 }, "lines": { - "covered": 233, + "covered": 232, "total": 424, - "percentage": 54.95 + "percentage": 54.71 } }, "src/vs/platform/agentHost/node/agentHostHeadlessTerminal.ts": { @@ -2898,9 +2964,9 @@ }, "src/vs/platform/agentHost/node/agentHostInputRequestTracker.ts": { "statements": { - "covered": 142, - "total": 160, - "percentage": 88.75 + "covered": 145, + "total": 163, + "percentage": 88.95 }, "branches": { "covered": 47, @@ -2909,13 +2975,13 @@ }, "functions": { "covered": 13, - "total": 13, - "percentage": 100 + "total": 14, + "percentage": 92.85 }, "lines": { - "covered": 142, - "total": 160, - "percentage": 88.75 + "covered": 145, + "total": 163, + "percentage": 88.95 } }, "src/vs/platform/agentHost/node/agentHostLocalTurns.ts": { @@ -2925,9 +2991,9 @@ "percentage": 80.74 }, "branches": { - "covered": 14, - "total": 23, - "percentage": 60.86 + "covered": 15, + "total": 24, + "percentage": 62.5 }, "functions": { "covered": 9, @@ -2991,9 +3057,9 @@ "percentage": 81 }, "branches": { - "covered": 25, - "total": 33, - "percentage": 75.75 + "covered": 24, + "total": 32, + "percentage": 75 }, "functions": { "covered": 5, @@ -3206,9 +3272,9 @@ }, "src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts": { "statements": { - "covered": 170, - "total": 308, - "percentage": 55.19 + "covered": 174, + "total": 314, + "percentage": 55.41 }, "branches": { "covered": 2, @@ -3217,13 +3283,13 @@ }, "functions": { "covered": 1, - "total": 14, - "percentage": 7.14 + "total": 15, + "percentage": 6.66 }, "lines": { - "covered": 170, - "total": 308, - "percentage": 55.19 + "covered": 174, + "total": 314, + "percentage": 55.41 } }, "src/vs/platform/agentHost/node/agentHostReviewService.ts": { @@ -3250,9 +3316,9 @@ }, "src/vs/platform/agentHost/node/agentHostServerMain.ts": { "statements": { - "covered": 453, - "total": 503, - "percentage": 90.05 + "covered": 458, + "total": 508, + "percentage": 90.15 }, "branches": { "covered": 23, @@ -3265,9 +3331,9 @@ "percentage": 85.71 }, "lines": { - "covered": 453, - "total": 503, - "percentage": 90.05 + "covered": 458, + "total": 508, + "percentage": 90.15 } }, "src/vs/platform/agentHost/node/agentHostSessionRepositories.ts": { @@ -3294,24 +3360,24 @@ }, "src/vs/platform/agentHost/node/agentHostSessionTitleController.ts": { "statements": { - "covered": 549, - "total": 694, - "percentage": 79.1 + "covered": 618, + "total": 831, + "percentage": 74.36 }, "branches": { - "covered": 100, - "total": 129, - "percentage": 77.51 + "covered": 118, + "total": 159, + "percentage": 74.21 }, "functions": { - "covered": 25, - "total": 34, - "percentage": 73.52 + "covered": 29, + "total": 42, + "percentage": 69.04 }, "lines": { - "covered": 549, - "total": 694, - "percentage": 79.1 + "covered": 618, + "total": 831, + "percentage": 74.36 } }, "src/vs/platform/agentHost/node/agentHostSessionTitleSignal.ts": { @@ -3360,14 +3426,14 @@ }, "src/vs/platform/agentHost/node/agentHostSkillCompletionProvider.ts": { "statements": { - "covered": 67, - "total": 129, - "percentage": 51.93 + "covered": 68, + "total": 130, + "percentage": 52.3 }, "branches": { - "covered": 5, - "total": 8, - "percentage": 62.5 + "covered": 6, + "total": 9, + "percentage": 66.66 }, "functions": { "covered": 2, @@ -3375,9 +3441,9 @@ "percentage": 33.33 }, "lines": { - "covered": 67, - "total": 129, - "percentage": 51.93 + "covered": 68, + "total": 130, + "percentage": 52.3 } }, "src/vs/platform/agentHost/node/agentHostSlashCompletion.ts": { @@ -3387,9 +3453,9 @@ "percentage": 89.53 }, "branches": { - "covered": 21, - "total": 26, - "percentage": 80.76 + "covered": 22, + "total": 27, + "percentage": 81.48 }, "functions": { "covered": 4, @@ -3405,96 +3471,118 @@ "src/vs/platform/agentHost/node/agentHostStateManager.ts": { "statements": { "covered": 1638, - "total": 1762, - "percentage": 92.96 + "total": 1776, + "percentage": 92.22 }, "branches": { - "covered": 264, - "total": 313, - "percentage": 84.34 + "covered": 263, + "total": 310, + "percentage": 84.83 }, "functions": { - "covered": 72, - "total": 77, - "percentage": 93.5 + "covered": 70, + "total": 78, + "percentage": 89.74 }, "lines": { "covered": 1638, - "total": 1762, - "percentage": 92.96 + "total": 1776, + "percentage": 92.22 + } + }, + "src/vs/platform/agentHost/node/agentHostStorageService.ts": { + "statements": { + "covered": 110, + "total": 121, + "percentage": 90.9 + }, + "branches": { + "covered": 17, + "total": 23, + "percentage": 73.91 + }, + "functions": { + "covered": 10, + "total": 10, + "percentage": 100 + }, + "lines": { + "covered": 110, + "total": 121, + "percentage": 90.9 } }, "src/vs/platform/agentHost/node/agentHostSyncOperationHandler.ts": { "statements": { - "covered": 31, + "covered": 67, "total": 80, - "percentage": 38.75 + "percentage": 83.75 }, "branches": { - "covered": 1, - "total": 1, - "percentage": 100 + "covered": 7, + "total": 15, + "percentage": 46.66 }, "functions": { - "covered": 1, + "covered": 3, "total": 3, - "percentage": 33.33 + "percentage": 100 }, "lines": { - "covered": 31, + "covered": 67, "total": 80, - "percentage": 38.75 + "percentage": 83.75 } }, "src/vs/platform/agentHost/node/agentHostSyncOperationProvider.ts": { "statements": { - "covered": 49, + "covered": 61, "total": 61, - "percentage": 80.32 + "percentage": 100 }, "branches": { - "covered": 8, - "total": 11, - "percentage": 72.72 + "covered": 13, + "total": 15, + "percentage": 86.66 }, "functions": { - "covered": 4, + "covered": 6, "total": 6, - "percentage": 66.66 + "percentage": 100 }, "lines": { - "covered": 49, + "covered": 61, "total": 61, - "percentage": 80.32 + "percentage": 100 } }, "src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts": { "statements": { - "covered": 1068, - "total": 1218, - "percentage": 87.68 + "covered": 1126, + "total": 1280, + "percentage": 87.96 }, "branches": { - "covered": 51, - "total": 73, - "percentage": 69.86 + "covered": 66, + "total": 96, + "percentage": 68.75 }, "functions": { - "covered": 15, - "total": 22, - "percentage": 68.18 + "covered": 16, + "total": 23, + "percentage": 69.56 }, "lines": { - "covered": 1068, - "total": 1218, - "percentage": 87.68 + "covered": 1126, + "total": 1280, + "percentage": 87.96 } }, "src/vs/platform/agentHost/node/agentHostTelemetryService.ts": { "statements": { "covered": 188, - "total": 277, - "percentage": 67.87 + "total": 278, + "percentage": 67.62 }, "branches": { "covered": 18, @@ -3508,8 +3596,8 @@ }, "lines": { "covered": 188, - "total": 277, - "percentage": 67.87 + "total": 278, + "percentage": 67.62 } }, "src/vs/platform/agentHost/node/agentHostTerminalManager.ts": { @@ -3519,9 +3607,9 @@ "percentage": 90.83 }, "branches": { - "covered": 120, - "total": 149, - "percentage": 80.53 + "covered": 119, + "total": 148, + "percentage": 80.4 }, "functions": { "covered": 35, @@ -3536,9 +3624,9 @@ }, "src/vs/platform/agentHost/node/agentHostToolCallTracker.ts": { "statements": { - "covered": 259, - "total": 299, - "percentage": 86.62 + "covered": 266, + "total": 309, + "percentage": 86.08 }, "branches": { "covered": 54, @@ -3547,35 +3635,35 @@ }, "functions": { "covered": 15, - "total": 15, - "percentage": 100 + "total": 16, + "percentage": 93.75 }, "lines": { - "covered": 259, - "total": 299, - "percentage": 86.62 + "covered": 266, + "total": 309, + "percentage": 86.08 } }, "src/vs/platform/agentHost/node/agentHostTurnTracker.ts": { "statements": { - "covered": 385, - "total": 484, - "percentage": 79.54 + "covered": 392, + "total": 495, + "percentage": 79.19 }, "branches": { - "covered": 42, - "total": 52, - "percentage": 80.76 + "covered": 34, + "total": 46, + "percentage": 73.91 }, "functions": { - "covered": 18, - "total": 22, - "percentage": 81.81 + "covered": 19, + "total": 23, + "percentage": 82.6 }, "lines": { - "covered": 385, - "total": 484, - "percentage": 79.54 + "covered": 392, + "total": 495, + "percentage": 79.19 } }, "src/vs/platform/agentHost/node/agentHostUpgradeChannel.ts": { @@ -3607,9 +3695,9 @@ "percentage": 71.12 }, "branches": { - "covered": 14, + "covered": 15, "total": 30, - "percentage": 46.66 + "percentage": 50 }, "functions": { "covered": 5, @@ -3646,46 +3734,46 @@ }, "src/vs/platform/agentHost/node/agentPeerChats.ts": { "statements": { - "covered": 186, - "total": 359, - "percentage": 51.81 + "covered": 204, + "total": 390, + "percentage": 52.3 }, "branches": { - "covered": 14, - "total": 46, - "percentage": 30.43 + "covered": 17, + "total": 50, + "percentage": 34 }, "functions": { - "covered": 6, - "total": 24, - "percentage": 25 + "covered": 7, + "total": 27, + "percentage": 25.92 }, "lines": { - "covered": 186, - "total": 359, - "percentage": 51.81 + "covered": 204, + "total": 390, + "percentage": 52.3 } }, "src/vs/platform/agentHost/node/agentPluginManager.ts": { "statements": { - "covered": 250, - "total": 292, - "percentage": 85.61 + "covered": 249, + "total": 294, + "percentage": 84.69 }, "branches": { - "covered": 32, - "total": 45, - "percentage": 71.11 + "covered": 30, + "total": 43, + "percentage": 69.76 }, "functions": { - "covered": 16, - "total": 19, - "percentage": 84.21 + "covered": 17, + "total": 20, + "percentage": 85 }, "lines": { - "covered": 250, - "total": 292, - "percentage": 85.61 + "covered": 249, + "total": 294, + "percentage": 84.69 } }, "src/vs/platform/agentHost/node/agentSdkDownloader.ts": { @@ -3712,68 +3800,68 @@ }, "src/vs/platform/agentHost/node/agentService.ts": { "statements": { - "covered": 4285, - "total": 5457, - "percentage": 78.52 + "covered": 4438, + "total": 5740, + "percentage": 77.31 }, "branches": { - "covered": 729, - "total": 1087, - "percentage": 67.06 + "covered": 772, + "total": 1153, + "percentage": 66.95 }, "functions": { - "covered": 174, - "total": 206, - "percentage": 84.46 + "covered": 188, + "total": 228, + "percentage": 82.45 }, "lines": { - "covered": 4285, - "total": 5457, - "percentage": 78.52 + "covered": 4438, + "total": 5740, + "percentage": 77.31 } }, "src/vs/platform/agentHost/node/agentSessionRegistry.ts": { "statements": { - "covered": 132, - "total": 138, - "percentage": 95.65 + "covered": 139, + "total": 156, + "percentage": 89.1 }, "branches": { - "covered": 10, - "total": 10, - "percentage": 100 + "covered": 12, + "total": 16, + "percentage": 75 }, "functions": { - "covered": 9, - "total": 12, - "percentage": 75 + "covered": 8, + "total": 11, + "percentage": 72.72 }, "lines": { - "covered": 132, - "total": 138, - "percentage": 95.65 + "covered": 139, + "total": 156, + "percentage": 89.1 } }, "src/vs/platform/agentHost/node/agentSideEffects.ts": { "statements": { - "covered": 1898, - "total": 2164, - "percentage": 87.7 + "covered": 2011, + "total": 2325, + "percentage": 86.49 }, "branches": { - "covered": 369, - "total": 468, - "percentage": 78.84 + "covered": 399, + "total": 513, + "percentage": 77.77 }, "functions": { - "covered": 62, - "total": 64, - "percentage": 96.87 + "covered": 66, + "total": 72, + "percentage": 91.66 }, "lines": { - "covered": 1898, - "total": 2164, - "percentage": 87.7 + "covered": 2011, + "total": 2325, + "percentage": 86.49 } }, "src/vs/platform/agentHost/node/appNodeModules.ts": { @@ -3866,68 +3954,68 @@ }, "src/vs/platform/agentHost/node/claude/claudeAgent.ts": { "statements": { - "covered": 2076, - "total": 2558, - "percentage": 81.15 + "covered": 2126, + "total": 2624, + "percentage": 81.02 }, "branches": { - "covered": 200, - "total": 302, - "percentage": 66.22 + "covered": 211, + "total": 319, + "percentage": 66.14 }, "functions": { - "covered": 89, - "total": 113, - "percentage": 78.76 + "covered": 95, + "total": 119, + "percentage": 79.83 }, "lines": { - "covered": 2076, - "total": 2558, - "percentage": 81.15 + "covered": 2126, + "total": 2624, + "percentage": 81.02 } }, "src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts": { "statements": { - "covered": 267, - "total": 312, - "percentage": 85.57 + "covered": 276, + "total": 323, + "percentage": 85.44 }, "branches": { - "covered": 13, - "total": 18, - "percentage": 72.22 + "covered": 14, + "total": 20, + "percentage": 70 }, "functions": { - "covered": 11, - "total": 15, - "percentage": 73.33 + "covered": 12, + "total": 16, + "percentage": 75 }, "lines": { - "covered": 267, - "total": 312, - "percentage": 85.57 + "covered": 276, + "total": 323, + "percentage": 85.44 } }, "src/vs/platform/agentHost/node/claude/claudeAgentSession.ts": { "statements": { - "covered": 1094, - "total": 1348, - "percentage": 81.15 + "covered": 1168, + "total": 1463, + "percentage": 79.83 }, "branches": { - "covered": 62, - "total": 95, - "percentage": 65.26 + "covered": 69, + "total": 111, + "percentage": 62.16 }, "functions": { - "covered": 33, - "total": 56, - "percentage": 58.92 + "covered": 37, + "total": 62, + "percentage": 59.67 }, "lines": { - "covered": 1094, - "total": 1348, - "percentage": 81.15 + "covered": 1168, + "total": 1463, + "percentage": 79.83 } }, "src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts": { @@ -3998,9 +4086,9 @@ }, "src/vs/platform/agentHost/node/claude/claudeFileEditObserver.ts": { "statements": { - "covered": 140, - "total": 149, - "percentage": 93.95 + "covered": 141, + "total": 150, + "percentage": 94 }, "branches": { "covered": 13, @@ -4013,9 +4101,9 @@ "percentage": 100 }, "lines": { - "covered": 140, - "total": 149, - "percentage": 93.95 + "covered": 141, + "total": 150, + "percentage": 94 } }, "src/vs/platform/agentHost/node/claude/claudeInteractiveTools.ts": { @@ -4130,9 +4218,9 @@ }, "src/vs/platform/agentHost/node/claude/claudePromptQueue.ts": { "statements": { - "covered": 155, - "total": 171, - "percentage": 90.64 + "covered": 157, + "total": 173, + "percentage": 90.75 }, "branches": { "covered": 16, @@ -4145,9 +4233,9 @@ "percentage": 90.9 }, "lines": { - "covered": 155, - "total": 171, - "percentage": 90.64 + "covered": 157, + "total": 173, + "percentage": 90.75 } }, "src/vs/platform/agentHost/node/claude/claudePromptResolver.ts": { @@ -4240,9 +4328,9 @@ }, "src/vs/platform/agentHost/node/claude/claudeSdkMessageRouter.ts": { "statements": { - "covered": 89, - "total": 93, - "percentage": 95.69 + "covered": 91, + "total": 95, + "percentage": 95.78 }, "branches": { "covered": 10, @@ -4255,43 +4343,43 @@ "percentage": 66.66 }, "lines": { - "covered": 89, - "total": 93, - "percentage": 95.69 + "covered": 91, + "total": 95, + "percentage": 95.78 } }, "src/vs/platform/agentHost/node/claude/claudeSdkOptions.ts": { "statements": { - "covered": 274, - "total": 334, - "percentage": 82.03 + "covered": 287, + "total": 347, + "percentage": 82.7 }, "branches": { - "covered": 13, - "total": 39, - "percentage": 33.33 + "covered": 14, + "total": 41, + "percentage": 34.14 }, "functions": { - "covered": 4, - "total": 7, - "percentage": 57.14 + "covered": 5, + "total": 8, + "percentage": 62.5 }, "lines": { - "covered": 274, - "total": 334, - "percentage": 82.03 + "covered": 287, + "total": 347, + "percentage": 82.7 } }, "src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts": { "statements": { - "covered": 552, - "total": 733, - "percentage": 75.3 + "covered": 557, + "total": 739, + "percentage": 75.37 }, "branches": { - "covered": 40, - "total": 63, - "percentage": 63.49 + "covered": 39, + "total": 62, + "percentage": 62.9 }, "functions": { "covered": 20, @@ -4299,9 +4387,9 @@ "percentage": 64.51 }, "lines": { - "covered": 552, - "total": 733, - "percentage": 75.3 + "covered": 557, + "total": 739, + "percentage": 75.37 } }, "src/vs/platform/agentHost/node/claude/claudeServerToolMcpServer.ts": { @@ -4328,24 +4416,24 @@ }, "src/vs/platform/agentHost/node/claude/claudeSessionMetadataStore.ts": { "statements": { - "covered": 201, - "total": 237, - "percentage": 84.81 + "covered": 225, + "total": 261, + "percentage": 86.2 }, "branches": { - "covered": 17, - "total": 35, - "percentage": 48.57 + "covered": 26, + "total": 41, + "percentage": 63.41 }, "functions": { - "covered": 8, - "total": 8, + "covered": 9, + "total": 9, "percentage": 100 }, "lines": { - "covered": 201, - "total": 237, - "percentage": 84.81 + "covered": 225, + "total": 261, + "percentage": 86.2 } }, "src/vs/platform/agentHost/node/claude/claudeSessionPermissionMode.ts": { @@ -4680,9 +4768,9 @@ }, "src/vs/platform/agentHost/node/claude/customizations/claudeSessionClientCustomizationsModel.ts": { "statements": { - "covered": 168, - "total": 233, - "percentage": 72.1 + "covered": 169, + "total": 237, + "percentage": 71.3 }, "branches": { "covered": 10, @@ -4695,21 +4783,21 @@ "percentage": 64.28 }, "lines": { - "covered": 168, - "total": 233, - "percentage": 72.1 + "covered": 169, + "total": 237, + "percentage": 71.3 } }, "src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationDiscovery.ts": { "statements": { "covered": 424, - "total": 551, - "percentage": 76.95 + "total": 550, + "percentage": 77.09 }, "branches": { - "covered": 44, - "total": 69, - "percentage": 63.76 + "covered": 43, + "total": 68, + "percentage": 63.23 }, "functions": { "covered": 12, @@ -4718,8 +4806,8 @@ }, "lines": { "covered": 424, - "total": 551, - "percentage": 76.95 + "total": 550, + "percentage": 77.09 } }, "src/vs/platform/agentHost/node/claude/customizations/scan/claudeAgentSkillScan.ts": { @@ -4856,90 +4944,112 @@ }, "src/vs/platform/agentHost/node/codex/codexAgent.ts": { "statements": { - "covered": 3879, - "total": 5890, - "percentage": 65.85 + "covered": 4112, + "total": 6217, + "percentage": 66.14 }, "branches": { - "covered": 364, - "total": 690, - "percentage": 52.75 + "covered": 411, + "total": 767, + "percentage": 53.58 }, "functions": { - "covered": 153, - "total": 210, - "percentage": 72.85 + "covered": 168, + "total": 227, + "percentage": 74 }, "lines": { - "covered": 3879, - "total": 5890, - "percentage": 65.85 + "covered": 4112, + "total": 6217, + "percentage": 66.14 } }, "src/vs/platform/agentHost/node/codex/codexAppServerClient.ts": { "statements": { - "covered": 406, + "covered": 414, "total": 481, - "percentage": 84.4 + "percentage": 86.07 }, "branches": { - "covered": 33, - "total": 57, - "percentage": 57.89 + "covered": 38, + "total": 59, + "percentage": 64.4 }, "functions": { - "covered": 16, + "covered": 17, "total": 19, - "percentage": 84.21 + "percentage": 89.47 }, "lines": { - "covered": 406, + "covered": 414, "total": 481, - "percentage": 84.4 + "percentage": 86.07 } }, "src/vs/platform/agentHost/node/codex/codexClientCustomizations.ts": { "statements": { - "covered": 260, - "total": 316, - "percentage": 82.27 + "covered": 300, + "total": 360, + "percentage": 83.33 }, "branches": { - "covered": 27, - "total": 51, - "percentage": 52.94 + "covered": 32, + "total": 62, + "percentage": 51.61 }, "functions": { - "covered": 16, - "total": 19, - "percentage": 84.21 + "covered": 19, + "total": 23, + "percentage": 82.6 }, "lines": { - "covered": 260, - "total": 316, - "percentage": 82.27 + "covered": 300, + "total": 360, + "percentage": 83.33 } }, "src/vs/platform/agentHost/node/codex/codexCustomizations.ts": { "statements": { - "covered": 175, - "total": 213, - "percentage": 82.15 + "covered": 213, + "total": 286, + "percentage": 74.47 }, "branches": { - "covered": 17, - "total": 30, - "percentage": 56.66 + "covered": 19, + "total": 36, + "percentage": 52.77 }, "functions": { - "covered": 7, - "total": 8, - "percentage": 87.5 + "covered": 8, + "total": 9, + "percentage": 88.88 + }, + "lines": { + "covered": 213, + "total": 286, + "percentage": 74.47 + } + }, + "src/vs/platform/agentHost/node/codex/codexDelegation.ts": { + "statements": { + "covered": 29, + "total": 46, + "percentage": 63.04 + }, + "branches": { + "covered": 5, + "total": 9, + "percentage": 55.55 + }, + "functions": { + "covered": 2, + "total": 3, + "percentage": 66.66 }, "lines": { - "covered": 175, - "total": 213, - "percentage": 82.15 + "covered": 29, + "total": 46, + "percentage": 63.04 } }, "src/vs/platform/agentHost/node/codex/codexElicitationMapper.ts": { @@ -5054,24 +5164,24 @@ }, "src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts": { "statements": { - "covered": 634, - "total": 1250, - "percentage": 50.72 + "covered": 629, + "total": 1258, + "percentage": 50 }, "branches": { - "covered": 63, - "total": 118, - "percentage": 53.38 + "covered": 62, + "total": 119, + "percentage": 52.1 }, "functions": { - "covered": 19, + "covered": 18, "total": 42, - "percentage": 45.23 + "percentage": 42.85 }, "lines": { - "covered": 634, - "total": 1250, - "percentage": 50.72 + "covered": 629, + "total": 1258, + "percentage": 50 } }, "src/vs/platform/agentHost/node/codex/codexMcpServers.ts": { @@ -5164,24 +5274,46 @@ }, "src/vs/platform/agentHost/node/codex/codexReplayMapper.ts": { "statements": { - "covered": 149, - "total": 293, - "percentage": 50.85 + "covered": 178, + "total": 378, + "percentage": 47.08 }, "branches": { "covered": 6, - "total": 19, - "percentage": 31.57 + "total": 26, + "percentage": 23.07 }, "functions": { "covered": 4, - "total": 10, - "percentage": 40 + "total": 13, + "percentage": 30.76 }, "lines": { - "covered": 149, - "total": 293, - "percentage": 50.85 + "covered": 178, + "total": 378, + "percentage": 47.08 + } + }, + "src/vs/platform/agentHost/node/codex/codexRolloutMetadata.ts": { + "statements": { + "covered": 117, + "total": 159, + "percentage": 73.58 + }, + "branches": { + "covered": 25, + "total": 34, + "percentage": 73.52 + }, + "functions": { + "covered": 5, + "total": 5, + "percentage": 100 + }, + "lines": { + "covered": 117, + "total": 159, + "percentage": 73.58 } }, "src/vs/platform/agentHost/node/codex/codexSessionConfigKeys.ts": { @@ -5208,24 +5340,24 @@ }, "src/vs/platform/agentHost/node/codex/codexSessionMetadataStore.ts": { "statements": { - "covered": 207, - "total": 232, - "percentage": 89.22 + "covered": 229, + "total": 256, + "percentage": 89.45 }, "branches": { - "covered": 16, - "total": 36, - "percentage": 44.44 + "covered": 18, + "total": 39, + "percentage": 46.15 }, "functions": { - "covered": 6, - "total": 6, + "covered": 7, + "total": 7, "percentage": 100 }, "lines": { - "covered": 207, - "total": 232, - "percentage": 89.22 + "covered": 229, + "total": 256, + "percentage": 89.45 } }, "src/vs/platform/agentHost/node/codex/codexShellCommand.ts": { @@ -5250,6 +5382,28 @@ "percentage": 85.71 } }, + "src/vs/platform/agentHost/node/codex/codexThreadCoordination.ts": { + "statements": { + "covered": 67, + "total": 189, + "percentage": 35.44 + }, + "branches": { + "covered": 1, + "total": 9, + "percentage": 11.11 + }, + "functions": { + "covered": 1, + "total": 8, + "percentage": 12.5 + }, + "lines": { + "covered": 67, + "total": 189, + "percentage": 35.44 + } + }, "src/vs/platform/agentHost/node/codex/codexThreadList.ts": { "statements": { "covered": 62, @@ -5318,24 +5472,24 @@ }, "src/vs/platform/agentHost/node/commandAutoApprover.ts": { "statements": { - "covered": 554, - "total": 695, - "percentage": 79.71 + "covered": 567, + "total": 704, + "percentage": 80.53 }, "branches": { - "covered": 37, + "covered": 39, "total": 82, - "percentage": 45.12 + "percentage": 47.56 }, "functions": { - "covered": 16, - "total": 19, - "percentage": 84.21 + "covered": 18, + "total": 21, + "percentage": 85.71 }, "lines": { - "covered": 554, - "total": 695, - "percentage": 79.71 + "covered": 567, + "total": 704, + "percentage": 80.53 } }, "src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts": { @@ -5428,46 +5582,46 @@ }, "src/vs/platform/agentHost/node/copilot/copilotAgent.ts": { "statements": { - "covered": 4060, - "total": 5376, - "percentage": 75.52 + "covered": 4365, + "total": 5802, + "percentage": 75.23 }, "branches": { - "covered": 648, - "total": 1003, - "percentage": 64.6 + "covered": 716, + "total": 1102, + "percentage": 64.97 }, "functions": { - "covered": 227, - "total": 277, - "percentage": 81.94 + "covered": 245, + "total": 300, + "percentage": 81.66 }, "lines": { - "covered": 4060, - "total": 5376, - "percentage": 75.52 + "covered": 4365, + "total": 5802, + "percentage": 75.23 } }, "src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts": { "statements": { - "covered": 4116, - "total": 5500, - "percentage": 74.83 + "covered": 4128, + "total": 5537, + "percentage": 74.55 }, "branches": { - "covered": 736, - "total": 1064, - "percentage": 69.17 + "covered": 740, + "total": 1074, + "percentage": 68.9 }, "functions": { - "covered": 167, - "total": 206, - "percentage": 81.06 + "covered": 174, + "total": 215, + "percentage": 80.93 }, "lines": { - "covered": 4116, - "total": 5500, - "percentage": 74.83 + "covered": 4128, + "total": 5537, + "percentage": 74.55 } }, "src/vs/platform/agentHost/node/copilot/copilotAttachmentUtils.ts": { @@ -5516,9 +5670,9 @@ }, "src/vs/platform/agentHost/node/copilot/copilotFailureTelemetry.ts": { "statements": { - "covered": 292, - "total": 402, - "percentage": 72.63 + "covered": 295, + "total": 405, + "percentage": 72.83 }, "branches": { "covered": 6, @@ -5531,16 +5685,16 @@ "percentage": 45.45 }, "lines": { - "covered": 292, - "total": 402, - "percentage": 72.63 + "covered": 295, + "total": 405, + "percentage": 72.83 } }, "src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts": { "statements": { - "covered": 218, - "total": 224, - "percentage": 97.32 + "covered": 250, + "total": 256, + "percentage": 97.65 }, "branches": { "covered": 5, @@ -5553,9 +5707,9 @@ "percentage": 100 }, "lines": { - "covered": 218, - "total": 224, - "percentage": 97.32 + "covered": 250, + "total": 256, + "percentage": 97.65 } }, "src/vs/platform/agentHost/node/copilot/copilotGitProject.ts": { @@ -5604,24 +5758,24 @@ }, "src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts": { "statements": { - "covered": 362, - "total": 516, - "percentage": 70.15 + "covered": 369, + "total": 518, + "percentage": 71.23 }, "branches": { - "covered": 41, - "total": 74, - "percentage": 55.4 + "covered": 42, + "total": 75, + "percentage": 56 }, "functions": { - "covered": 18, + "covered": 19, "total": 25, - "percentage": 72 + "percentage": 76 }, "lines": { - "covered": 362, - "total": 516, - "percentage": 70.15 + "covered": 369, + "total": 518, + "percentage": 71.23 } }, "src/vs/platform/agentHost/node/copilot/copilotSdkChatError.ts": { @@ -5648,24 +5802,24 @@ }, "src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts": { "statements": { - "covered": 565, - "total": 688, - "percentage": 82.12 + "covered": 677, + "total": 860, + "percentage": 78.72 }, "branches": { - "covered": 63, - "total": 95, - "percentage": 66.31 + "covered": 80, + "total": 120, + "percentage": 66.66 }, "functions": { - "covered": 25, - "total": 31, - "percentage": 80.64 + "covered": 34, + "total": 45, + "percentage": 75.55 }, "lines": { - "covered": 565, - "total": 688, - "percentage": 82.12 + "covered": 677, + "total": 860, + "percentage": 78.72 } }, "src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts": { @@ -5714,9 +5868,9 @@ }, "src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts": { "statements": { - "covered": 99, - "total": 269, - "percentage": 36.8 + "covered": 100, + "total": 270, + "percentage": 37.03 }, "branches": { "covered": 2, @@ -5729,9 +5883,9 @@ "percentage": 25 }, "lines": { - "covered": 99, - "total": 269, - "percentage": 36.8 + "covered": 100, + "total": 270, + "percentage": 37.03 } }, "src/vs/platform/agentHost/node/copilot/copilotSlashCommandProvider.ts": { @@ -5780,9 +5934,9 @@ }, "src/vs/platform/agentHost/node/copilot/copilotTodoStoreTelemetry.ts": { "statements": { - "covered": 167, - "total": 238, - "percentage": 70.16 + "covered": 170, + "total": 241, + "percentage": 70.53 }, "branches": { "covered": 40, @@ -5795,9 +5949,9 @@ "percentage": 77.77 }, "lines": { - "covered": 167, - "total": 238, - "percentage": 70.16 + "covered": 170, + "total": 241, + "percentage": 70.53 } }, "src/vs/platform/agentHost/node/copilot/copilotTokenFields.ts": { @@ -5830,8 +5984,8 @@ }, "branches": { "covered": 164, - "total": 276, - "percentage": 59.42 + "total": 277, + "percentage": 59.2 }, "functions": { "covered": 27, @@ -5846,9 +6000,9 @@ }, "src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts": { "statements": { - "covered": 662, - "total": 944, - "percentage": 70.12 + "covered": 666, + "total": 948, + "percentage": 70.25 }, "branches": { "covered": 87, @@ -5861,9 +6015,9 @@ "percentage": 95.45 }, "lines": { - "covered": 662, - "total": 944, - "percentage": 70.12 + "covered": 666, + "total": 948, + "percentage": 70.25 } }, "src/vs/platform/agentHost/node/copilot/modelIdentifiers.ts": { @@ -5934,9 +6088,9 @@ }, "src/vs/platform/agentHost/node/copilot/prompts/anthropicPrompt.ts": { "statements": { - "covered": 49, - "total": 72, - "percentage": 68.05 + "covered": 47, + "total": 66, + "percentage": 71.21 }, "branches": { "covered": 3, @@ -5949,21 +6103,21 @@ "percentage": 60 }, "lines": { - "covered": 49, - "total": 72, - "percentage": 68.05 + "covered": 47, + "total": 66, + "percentage": 71.21 } }, "src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts": { "statements": { - "covered": 221, - "total": 241, - "percentage": 91.7 + "covered": 206, + "total": 229, + "percentage": 89.95 }, "branches": { - "covered": 16, - "total": 26, - "percentage": 61.53 + "covered": 13, + "total": 23, + "percentage": 56.52 }, "functions": { "covered": 7, @@ -5971,31 +6125,31 @@ "percentage": 100 }, "lines": { - "covered": 221, - "total": 241, - "percentage": 91.7 + "covered": 206, + "total": 229, + "percentage": 89.95 } }, "src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts": { "statements": { - "covered": 109, - "total": 117, - "percentage": 93.16 + "covered": 115, + "total": 126, + "percentage": 91.26 }, "branches": { "covered": 5, - "total": 12, - "percentage": 41.66 + "total": 11, + "percentage": 45.45 }, "functions": { "covered": 2, - "total": 4, - "percentage": 50 + "total": 5, + "percentage": 40 }, "lines": { - "covered": 109, - "total": 117, - "percentage": 93.16 + "covered": 115, + "total": 126, + "percentage": 91.26 } }, "src/vs/platform/agentHost/node/copilot/prompts/toolInstructions.ts": { @@ -6176,24 +6330,24 @@ }, "src/vs/platform/agentHost/node/localCommands/localChatCommand.ts": { "statements": { - "covered": 240, - "total": 251, - "percentage": 95.61 + "covered": 244, + "total": 255, + "percentage": 95.68 }, "branches": { - "covered": 28, - "total": 36, - "percentage": 77.77 + "covered": 29, + "total": 37, + "percentage": 78.37 }, "functions": { - "covered": 12, - "total": 12, + "covered": 13, + "total": 13, "percentage": 100 }, "lines": { - "covered": 240, - "total": 251, - "percentage": 95.61 + "covered": 244, + "total": 255, + "percentage": 95.68 } }, "src/vs/platform/agentHost/node/localCommands/localChatCommands.contribution.ts": { @@ -6220,14 +6374,14 @@ }, "src/vs/platform/agentHost/node/localCommands/renameLocalCommand.ts": { "statements": { - "covered": 70, - "total": 70, + "covered": 75, + "total": 75, "percentage": 100 }, "branches": { - "covered": 18, - "total": 19, - "percentage": 94.73 + "covered": 14, + "total": 15, + "percentage": 93.33 }, "functions": { "covered": 5, @@ -6235,8 +6389,8 @@ "percentage": 100 }, "lines": { - "covered": 70, - "total": 70, + "covered": 75, + "total": 75, "percentage": 100 } }, @@ -6308,24 +6462,24 @@ }, "src/vs/platform/agentHost/node/protocolServerHandler.ts": { "statements": { - "covered": 1590, - "total": 1813, - "percentage": 87.69 + "covered": 1603, + "total": 1830, + "percentage": 87.59 }, "branches": { - "covered": 300, - "total": 375, - "percentage": 80 + "covered": 303, + "total": 380, + "percentage": 79.73 }, "functions": { - "covered": 75, - "total": 85, - "percentage": 88.23 + "covered": 77, + "total": 88, + "percentage": 87.5 }, "lines": { - "covered": 1590, - "total": 1813, - "percentage": 87.69 + "covered": 1603, + "total": 1830, + "percentage": 87.59 } }, "src/vs/platform/agentHost/node/serverUrls.ts": { @@ -6352,14 +6506,14 @@ }, "src/vs/platform/agentHost/node/sessionDataService.ts": { "statements": { - "covered": 155, + "covered": 157, "total": 198, - "percentage": 78.28 + "percentage": 79.29 }, "branches": { - "covered": 20, - "total": 24, - "percentage": 83.33 + "covered": 22, + "total": 25, + "percentage": 88 }, "functions": { "covered": 12, @@ -6367,16 +6521,16 @@ "percentage": 85.71 }, "lines": { - "covered": 155, + "covered": 157, "total": 198, - "percentage": 78.28 + "percentage": 79.29 } }, "src/vs/platform/agentHost/node/sessionDatabase.ts": { "statements": { - "covered": 742, - "total": 872, - "percentage": 85.09 + "covered": 736, + "total": 882, + "percentage": 83.44 }, "branches": { "covered": 107, @@ -6385,13 +6539,13 @@ }, "functions": { "covered": 39, - "total": 53, - "percentage": 73.58 + "total": 54, + "percentage": 72.22 }, "lines": { - "covered": 742, - "total": 872, - "percentage": 85.09 + "covered": 736, + "total": 882, + "percentage": 83.44 } }, "src/vs/platform/agentHost/node/sessionDiffAggregator.ts": { @@ -6418,14 +6572,14 @@ }, "src/vs/platform/agentHost/node/sessionPermissions.ts": { "statements": { - "covered": 536, - "total": 694, - "percentage": 77.23 + "covered": 529, + "total": 687, + "percentage": 77 }, "branches": { - "covered": 86, - "total": 127, - "percentage": 67.71 + "covered": 89, + "total": 131, + "percentage": 67.93 }, "functions": { "covered": 23, @@ -6433,9 +6587,9 @@ "percentage": 82.14 }, "lines": { - "covered": 536, - "total": 694, - "percentage": 77.23 + "covered": 529, + "total": 687, + "percentage": 77 } }, "src/vs/platform/agentHost/node/shared/agentBranchNameGenerator.ts": { @@ -6484,24 +6638,24 @@ }, "src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts": { "statements": { - "covered": 561, - "total": 608, - "percentage": 92.26 + "covered": 564, + "total": 611, + "percentage": 92.3 }, "branches": { - "covered": 59, - "total": 91, - "percentage": 64.83 + "covered": 60, + "total": 92, + "percentage": 65.21 }, "functions": { - "covered": 24, - "total": 25, - "percentage": 96 + "covered": 25, + "total": 26, + "percentage": 96.15 }, "lines": { - "covered": 561, - "total": 608, - "percentage": 92.26 + "covered": 564, + "total": 611, + "percentage": 92.3 } }, "src/vs/platform/agentHost/node/shared/agentHostOctoKitService.ts": { @@ -6528,24 +6682,24 @@ }, "src/vs/platform/agentHost/node/shared/agentServerToolHost.ts": { "statements": { - "covered": 153, - "total": 158, - "percentage": 96.83 + "covered": 171, + "total": 181, + "percentage": 94.47 }, "branches": { - "covered": 11, - "total": 15, - "percentage": 73.33 + "covered": 19, + "total": 26, + "percentage": 73.07 }, "functions": { - "covered": 5, - "total": 5, + "covered": 8, + "total": 8, "percentage": 100 }, "lines": { - "covered": 153, - "total": 158, - "percentage": 96.83 + "covered": 171, + "total": 181, + "percentage": 94.47 } }, "src/vs/platform/agentHost/node/shared/arcToolEdit.ts": { @@ -6572,31 +6726,53 @@ }, "src/vs/platform/agentHost/node/shared/copilotApiService.ts": { "statements": { - "covered": 1100, - "total": 1276, - "percentage": 86.2 + "covered": 1112, + "total": 1286, + "percentage": 86.46 }, "branches": { - "covered": 57, - "total": 102, - "percentage": 55.88 + "covered": 58, + "total": 103, + "percentage": 56.31 }, "functions": { - "covered": 25, - "total": 28, - "percentage": 89.28 + "covered": 27, + "total": 29, + "percentage": 93.1 }, "lines": { - "covered": 1100, - "total": 1276, - "percentage": 86.2 + "covered": 1112, + "total": 1286, + "percentage": 86.46 + } + }, + "src/vs/platform/agentHost/node/shared/customizationEnablementGate.ts": { + "statements": { + "covered": 154, + "total": 185, + "percentage": 83.24 + }, + "branches": { + "covered": 47, + "total": 56, + "percentage": 83.92 + }, + "functions": { + "covered": 8, + "total": 9, + "percentage": 88.88 + }, + "lines": { + "covered": 154, + "total": 185, + "percentage": 83.24 } }, "src/vs/platform/agentHost/node/shared/editArcReporter.ts": { "statements": { - "covered": 142, - "total": 373, - "percentage": 38.06 + "covered": 145, + "total": 393, + "percentage": 36.89 }, "branches": { "covered": 6, @@ -6609,9 +6785,9 @@ "percentage": 25 }, "lines": { - "covered": 142, - "total": 373, - "percentage": 38.06 + "covered": 145, + "total": 393, + "percentage": 36.89 } }, "src/vs/platform/agentHost/node/shared/editChunkExtractor.ts": { @@ -6638,9 +6814,9 @@ }, "src/vs/platform/agentHost/node/shared/editSurvivalReporter.ts": { "statements": { - "covered": 246, - "total": 262, - "percentage": 93.89 + "covered": 250, + "total": 266, + "percentage": 93.98 }, "branches": { "covered": 22, @@ -6653,9 +6829,9 @@ "percentage": 62.5 }, "lines": { - "covered": 246, - "total": 262, - "percentage": 93.89 + "covered": 250, + "total": 266, + "percentage": 93.98 } }, "src/vs/platform/agentHost/node/shared/editSurvivalTracker.ts": { @@ -6682,14 +6858,14 @@ }, "src/vs/platform/agentHost/node/shared/fileEditTracker.ts": { "statements": { - "covered": 236, - "total": 250, - "percentage": 94.4 + "covered": 237, + "total": 253, + "percentage": 93.67 }, "branches": { "covered": 36, - "total": 42, - "percentage": 85.71 + "total": 43, + "percentage": 83.72 }, "functions": { "covered": 7, @@ -6697,9 +6873,9 @@ "percentage": 100 }, "lines": { - "covered": 236, - "total": 250, - "percentage": 94.4 + "covered": 237, + "total": 253, + "percentage": 93.67 } }, "src/vs/platform/agentHost/node/shared/loopbackProxyServer.ts": { @@ -6726,46 +6902,46 @@ }, "src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts": { "statements": { - "covered": 438, - "total": 528, - "percentage": 82.95 + "covered": 459, + "total": 556, + "percentage": 82.55 }, "branches": { - "covered": 65, - "total": 78, - "percentage": 83.33 + "covered": 83, + "total": 103, + "percentage": 80.58 }, "functions": { - "covered": 21, - "total": 28, - "percentage": 75 + "covered": 25, + "total": 31, + "percentage": 80.64 }, "lines": { - "covered": 438, - "total": 528, - "percentage": 82.95 + "covered": 459, + "total": 556, + "percentage": 82.55 } }, "src/vs/platform/agentHost/node/shared/persistSessionMetadata.ts": { "statements": { - "covered": 27, - "total": 31, - "percentage": 87.09 + "covered": 48, + "total": 60, + "percentage": 80 }, "branches": { - "covered": 2, - "total": 3, - "percentage": 66.66 + "covered": 4, + "total": 5, + "percentage": 80 }, "functions": { - "covered": 1, - "total": 2, - "percentage": 50 + "covered": 3, + "total": 5, + "percentage": 60 }, "lines": { - "covered": 27, - "total": 31, - "percentage": 87.09 + "covered": 48, + "total": 60, + "percentage": 80 } }, "src/vs/platform/agentHost/node/shared/proxyChatError.ts": { @@ -6814,24 +6990,24 @@ }, "src/vs/platform/agentHost/node/shared/sessionServerTools.ts": { "statements": { - "covered": 982, - "total": 1085, - "percentage": 90.5 + "covered": 1033, + "total": 1258, + "percentage": 82.11 }, "branches": { - "covered": 147, - "total": 237, - "percentage": 62.02 + "covered": 149, + "total": 241, + "percentage": 61.82 }, "functions": { - "covered": 47, - "total": 49, - "percentage": 95.91 + "covered": 48, + "total": 58, + "percentage": 82.75 }, "lines": { - "covered": 982, - "total": 1085, - "percentage": 90.5 + "covered": 1033, + "total": 1258, + "percentage": 82.11 } }, "src/vs/platform/agentHost/node/shared/shellCommandExecution.ts": { @@ -6858,24 +7034,24 @@ }, "src/vs/platform/agentHost/node/shared/worktreeIsolation.ts": { "statements": { - "covered": 819, - "total": 1106, - "percentage": 74.05 + "covered": 856, + "total": 1156, + "percentage": 74.04 }, "branches": { - "covered": 87, - "total": 148, - "percentage": 58.78 + "covered": 91, + "total": 155, + "percentage": 58.7 }, "functions": { - "covered": 37, - "total": 48, - "percentage": 77.08 + "covered": 39, + "total": 50, + "percentage": 78 }, "lines": { - "covered": 819, - "total": 1106, - "percentage": 74.05 + "covered": 856, + "total": 1156, + "percentage": 74.04 } }, "src/vs/platform/agentHost/node/webSocketTransport.ts": { diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts index 347fdde4c70869..e6eb9d769d1888 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts @@ -23,7 +23,7 @@ */ import assert from 'assert'; -import { execSync } from 'child_process'; +import { execFileSync, execSync } from 'child_process'; import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { retry } from '../../../../../../base/common/async.js'; @@ -33,9 +33,9 @@ import { generateUuid } from '../../../../../../base/common/uuid.js'; import type { ListSessionsResult, ResourceReadResult, SubscribeResult } from '../../../../common/state/protocol/commands.js'; import { ContentEncoding } from '../../../../common/state/protocol/common/commands.js'; import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/registry.js'; -import { ChangesetOperationTargetKind } from '../../../../common/state/protocol/channels-changeset/commands.js'; +import { ChangesetOperationTargetKind, type InvokeChangesetOperationResult } from '../../../../common/state/protocol/channels-changeset/commands.js'; import { ActionType } from '../../../../common/state/sessionActions.js'; -import { buildChatUri, buildDefaultChatUri, MessageKind, ROOT_STATE_URI, type SessionState } from '../../../../common/state/sessionState.js'; +import { buildChatUri, buildDefaultChatUri, MessageKind, readSessionGitState, ROOT_STATE_URI, type SessionState } from '../../../../common/state/sessionState.js'; import { ChangesetKind, buildBranchChangesetUri, @@ -79,6 +79,13 @@ interface IOperationStatusChangedAction { readonly status: string; } +interface IObservedChangesetState { + readonly status: string; + readonly files: readonly IObservedChangesetFile[]; + readonly operations?: readonly IObservedOperation[]; + readonly error?: { readonly message?: string }; +} + const CHANGESET_OPERATION_TIMEOUT_MS = 60_000; export function defineChangesetTests(context: IAgentHostE2ETestContext): void { @@ -105,6 +112,33 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { return workspace; } + function createRemoteGitWorkspace(prefix: string): { readonly workspace: string; readonly remote: string } { + const workspace = createGitWorkspace(`${prefix}-workspace-`); + const remote = mkdtempSync(join(tmpdir(), `${prefix}-remote-`)); + tempDirs.push(remote); + execFileSync('git', ['init', '--bare', '-q'], { cwd: remote }); + execFileSync('git', ['remote', 'add', 'origin', remote], { cwd: workspace }); + execFileSync('git', ['push', '-q', '-u', 'origin', 'HEAD'], { cwd: workspace }); + execFileSync('git', ['config', 'pull.rebase', 'false'], { cwd: workspace }); + return { workspace, remote }; + } + + function commitFile(workspace: string, file: string, contents: string, message: string): void { + writeFileSync(join(workspace, file), contents); + execFileSync('git', ['add', file], { cwd: workspace }); + execFileSync('git', ['commit', '-q', '-m', message], { cwd: workspace }); + } + + function pushRemoteCommit(remote: string, prefix: string, file: string, contents: string): void { + const clone = mkdtempSync(join(tmpdir(), `${prefix}-clone-`)); + tempDirs.push(clone); + execFileSync('git', ['clone', '-q', remote, '.'], { cwd: clone }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: clone }); + execFileSync('git', ['config', 'user.name', 'Agent Host E2E'], { cwd: clone }); + commitFile(clone, file, contents, `add ${file}`); + execFileSync('git', ['push', '-q'], { cwd: clone }); + } + async function createSessionIn(workspace: string, prefix: string): Promise { return createRealSession(context.client, config, `${prefix}-${config.provider}`, createdSessions, URI.file(workspace)); } @@ -200,9 +234,9 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { ); } - async function changesetState(channel: string): Promise<{ readonly status: string; readonly files: readonly IObservedChangesetFile[]; readonly error?: { readonly message?: string } }> { + async function changesetState(channel: string): Promise { const result = await context.client.call('subscribe', { channel }); - let state = result.snapshot!.state as { readonly status: string; readonly files: readonly IObservedChangesetFile[]; readonly error?: { readonly message?: string } }; + let state = result.snapshot!.state as IObservedChangesetState; if (state.status === 'computing') { await context.client.waitForNotification(n => isActionNotification(n, 'changeset/statusChanged') @@ -215,6 +249,50 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { return state; } + async function waitForOperation(channel: string, operationId: string): Promise { + return retry(async () => { + const operation = (await changesetState(channel)).operations?.find(operation => operation.id === operationId); + if (!operation || operation.status !== 'idle') { + throw new Error(`Changeset ${channel} has not advertised idle operation ${operationId}`); + } + return operation; + }, 100, 100); + } + + async function waitForOperationRemoved(channel: string, operationId: string): Promise { + await retry(async () => { + if ((await changesetState(channel)).operations?.some(operation => operation.id === operationId)) { + throw new Error(`Changeset ${channel} still advertises operation ${operationId}`); + } + }, 100, 100); + } + + async function invokeChangesetOperation(channel: string, operationId: string): Promise<{ + readonly result: InvokeChangesetOperationResult; + readonly statuses: readonly string[]; + }> { + context.client.clearReceived(); + const completed = context.client.waitForNotification(n => + isActionNotification(n, 'changeset/operationStatusChanged') + && getActionEnvelope(n).channel === channel + && (getActionEnvelope(n).action as IOperationStatusChangedAction).operationId === operationId + && (getActionEnvelope(n).action as IOperationStatusChangedAction).status === 'idle', + CHANGESET_OPERATION_TIMEOUT_MS, + ); + const result = await context.client.call('invokeChangesetOperation', { + channel, + operationId, + }, CHANGESET_OPERATION_TIMEOUT_MS); + await completed; + const statuses = context.client.receivedNotifications(n => + isActionNotification(n, 'changeset/operationStatusChanged') + && getActionEnvelope(n).channel === channel, + ).map(n => getActionEnvelope(n).action as IOperationStatusChangedAction) + .filter(action => action.operationId === operationId) + .map(action => action.status); + return { result, statuses }; + } + async function waitForChangesetFiles(channel: string, basenames: readonly string[]): Promise { return retry(async () => { const state = await changesetState(channel); @@ -711,6 +789,125 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { ]); }); + conformanceTest(context, 'a branch with an upstream and no outgoing commits omits sync', async function () { + const { workspace } = createRemoteGitWorkspace('ahp-sync-none'); + const sessionUri = await createSessionIn(workspace, 'sync-none'); + const changeset = buildUncommittedChangesetUri(sessionUri); + + await retry(async () => { + const subscribed = await context.client.call('subscribe', { channel: sessionUri }); + const gitState = readSessionGitState((subscribed.snapshot!.state as SessionState)._meta); + if (!gitState?.upstreamBranchName || gitState.outgoingChanges !== 0) { + throw new Error('Session Git state has not confirmed an up-to-date upstream'); + } + }, 100, 100); + const state = await changesetState(changeset); + + assert.strictEqual(state.operations?.some(operation => operation.id === 'sync') ?? false, false); + }); + + conformanceTest(context, 'an outgoing commit advertises a changeset-scoped sync operation', async function () { + const { workspace } = createRemoteGitWorkspace('ahp-sync-advertise'); + commitFile(workspace, 'outgoing.txt', 'outgoing\n', 'add outgoing'); + const sessionUri = await createSessionIn(workspace, 'sync-advertise'); + const changeset = buildUncommittedChangesetUri(sessionUri); + + const operation = await waitForOperation(changeset, 'sync'); + + assert.deepStrictEqual({ + id: operation.id, + scopes: operation.scopes, + status: operation.status, + }, { + id: 'sync', + scopes: ['changeset'], + status: 'idle', + }); + }); + + conformanceTest(context, 'sync pushes an outgoing commit and clears the operation', async function () { + const { workspace, remote } = createRemoteGitWorkspace('ahp-sync-push'); + commitFile(workspace, 'outgoing.txt', 'outgoing\n', 'add outgoing'); + const sessionUri = await createSessionIn(workspace, 'sync-push'); + const changeset = buildUncommittedChangesetUri(sessionUri); + await waitForOperation(changeset, 'sync'); + + const invoked = await invokeChangesetOperation(changeset, 'sync'); + await waitForOperationRemoved(changeset, 'sync'); + const localHead = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: workspace, encoding: 'utf8' }).trim(); + const remoteHead = execFileSync('git', ['--git-dir', remote, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); + + assert.deepStrictEqual({ + message: typeof invoked.result.message === 'string' ? invoked.result.message : invoked.result.message?.markdown, + statuses: invoked.statuses, + remoteMatchesLocal: remoteHead === localHead, + }, { + message: 'Synced changes.', + statuses: ['running', 'idle'], + remoteMatchesLocal: true, + }); + }); + + conformanceTest(context, 'sync pulls a non-conflicting remote commit before pushing', async function () { + const { workspace, remote } = createRemoteGitWorkspace('ahp-sync-diverged'); + commitFile(workspace, 'outgoing.txt', 'outgoing\n', 'add outgoing'); + pushRemoteCommit(remote, 'ahp-sync-diverged', 'incoming.txt', 'incoming\n'); + const sessionUri = await createSessionIn(workspace, 'sync-diverged'); + const changeset = buildUncommittedChangesetUri(sessionUri); + await waitForOperation(changeset, 'sync'); + + await invokeChangesetOperation(changeset, 'sync'); + const localHead = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: workspace, encoding: 'utf8' }).trim(); + const remoteHead = execFileSync('git', ['--git-dir', remote, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); + + assert.deepStrictEqual({ + incoming: readFileSync(join(workspace, 'incoming.txt'), 'utf8').replaceAll('\r\n', '\n'), + outgoing: readFileSync(join(workspace, 'outgoing.txt'), 'utf8').replaceAll('\r\n', '\n'), + remoteMatchesLocal: remoteHead === localHead, + }, { + incoming: 'incoming\n', + outgoing: 'outgoing\n', + remoteMatchesLocal: true, + }); + }); + + conformanceTest(context, 'sync reports an error when its upstream becomes unreachable', async function () { + const { workspace } = createRemoteGitWorkspace('ahp-sync-failure'); + commitFile(workspace, 'outgoing.txt', 'outgoing\n', 'add outgoing'); + const sessionUri = await createSessionIn(workspace, 'sync-failure'); + const changeset = buildUncommittedChangesetUri(sessionUri); + await waitForOperation(changeset, 'sync'); + execFileSync('git', ['remote', 'set-url', 'origin', join(workspace, 'missing-remote')], { cwd: workspace }); + context.client.clearReceived(); + const failed = context.client.waitForNotification(n => + isActionNotification(n, 'changeset/operationStatusChanged') + && getActionEnvelope(n).channel === changeset + && (getActionEnvelope(n).action as IOperationStatusChangedAction).operationId === 'sync' + && (getActionEnvelope(n).action as IOperationStatusChangedAction).status === 'error', + CHANGESET_OPERATION_TIMEOUT_MS, + ); + + await assert.rejects(context.client.call('invokeChangesetOperation', { + channel: changeset, + operationId: 'sync', + }, CHANGESET_OPERATION_TIMEOUT_MS), /Failed to sync changes/); + await failed; + const statuses = context.client.receivedNotifications(n => + isActionNotification(n, 'changeset/operationStatusChanged') + && getActionEnvelope(n).channel === changeset, + ).map(n => getActionEnvelope(n).action as IOperationStatusChangedAction) + .filter(action => action.operationId === 'sync') + .map(action => action.status); + + assert.deepStrictEqual({ + statuses, + outgoingPreserved: readFileSync(join(workspace, 'outgoing.txt'), 'utf8').replaceAll('\r\n', '\n'), + }, { + statuses: ['running', 'error'], + outgoingPreserved: 'outgoing\n', + }); + }); + conformanceTest(context, 'discarding a tracked change restores the file and reports operation status', async function () { const { workspace, changeset, file } = await createModifiedUncommittedChangeset('changeset-discard'); const resource = file.edit.after?.uri; diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/clientFilesystemSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/clientFilesystemSuite.ts index 14aaf285c6e073..61bc4a5897f74f 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/clientFilesystemSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/clientFilesystemSuite.ts @@ -475,6 +475,122 @@ export function defineClientFilesystemTests(context: IAgentHostE2ETestContext): assert.strictEqual(readFileSync(join(root, 'created.txt'), 'utf8'), 'created'); }); + // The host currently ignores the requested encoding; see KNOWN_ISSUES.md. + conformanceTest(context, 'resourceRead returns requested base64 content without byte loss', async function () { + await initializeClient('resource-read-base64'); + const root = createWorkspace('ahp-resource-read-base64-'); + const bytes = Buffer.from([0, 1, 127, 128, 254, 255]); + writeFileSync(join(root, 'bytes.bin'), bytes); + + const result = await context.client.call('resourceRead', { + channel: ROOT_STATE_URI, + uri: fileUri(root, 'bytes.bin'), + encoding: ContentEncoding.Base64, + }); + + assert.deepStrictEqual({ + encoding: result.encoding, + bytes: [...Buffer.from(result.data, 'base64')], + }, { + encoding: ContentEncoding.Base64, + bytes: [...bytes], + }); + }, context.runHostOnlyKnownIssueTests); + + conformanceTest(context, 'resourceResolve returns versioned file metadata', async function () { + await initializeClient('resource-resolve-metadata'); + const root = createWorkspace('ahp-resource-resolve-metadata-'); + const file = fileUri(root, 'metadata.txt'); + writeFileSync(join(root, 'metadata.txt'), 'metadata'); + + const result = await context.client.call('resourceResolve', { + channel: ROOT_STATE_URI, + uri: file, + followSymlinks: false, + }); + + assert.deepStrictEqual({ + uri: result.uri, + type: result.type, + size: result.size, + mtimeIsIso: typeof result.mtime === 'string' && new Date(result.mtime).toISOString() === result.mtime, + ctimeIsIso: typeof result.ctime === 'string' && new Date(result.ctime).toISOString() === result.ctime, + hasEtag: typeof result.etag === 'string' && result.etag.length > 0, + }, { + uri: file, + type: ResourceType.File, + size: 'metadata'.length, + mtimeIsIso: true, + ctimeIsIso: true, + hasEtag: true, + }); + }); + + conformanceTest(context, 'resourceWrite createOnly atomically creates a missing file', async function () { + await initializeClient('resource-create-only-new'); + const root = createWorkspace('ahp-resource-create-only-new-'); + const file = fileUri(root, 'created.txt'); + + await writeText(file, 'created', { createOnly: true }); + + assert.strictEqual(readFileSync(join(root, 'created.txt'), 'utf8'), 'created'); + }); + + conformanceTest(context, 'resourceWrite createOnly supports append creation mode', async function () { + await initializeClient('resource-create-only-append'); + const root = createWorkspace('ahp-resource-create-only-append-'); + const file = fileUri(root, 'created.txt'); + + await writeText(file, 'created', { createOnly: true, mode: ResourceWriteMode.Append }); + + assert.strictEqual(readFileSync(join(root, 'created.txt'), 'utf8'), 'created'); + }); + + conformanceTest(context, 'resourceWrite append position beyond EOF prepends content', async function () { + await initializeClient('resource-append-before-start'); + const root = createWorkspace('ahp-resource-append-before-start-'); + const file = fileUri(root, 'append.txt'); + writeFileSync(join(root, 'append.txt'), 'TAIL'); + + await writeText(file, 'HEAD-', { mode: ResourceWriteMode.Append, position: 100 }); + + assert.strictEqual(readFileSync(join(root, 'append.txt'), 'utf8'), 'HEAD-TAIL'); + }); + + conformanceTest(context, 'resourceWrite insert position beyond EOF appends content', async function () { + await initializeClient('resource-insert-after-end'); + const root = createWorkspace('ahp-resource-insert-after-end-'); + const file = fileUri(root, 'insert.txt'); + writeFileSync(join(root, 'insert.txt'), 'HEAD'); + + await writeText(file, '-TAIL', { mode: ResourceWriteMode.Insert, position: 100 }); + + assert.strictEqual(readFileSync(join(root, 'insert.txt'), 'utf8'), 'HEAD-TAIL'); + }); + + conformanceTest(context, 'resourceWrite truncate position beyond EOF appends without padding', async function () { + await initializeClient('resource-truncate-after-end'); + const root = createWorkspace('ahp-resource-truncate-after-end-'); + const file = fileUri(root, 'truncate.txt'); + writeFileSync(join(root, 'truncate.txt'), 'HEAD'); + + await writeText(file, '-TAIL', { mode: ResourceWriteMode.Truncate, position: 100 }); + + assert.strictEqual(readFileSync(join(root, 'truncate.txt'), 'utf8'), 'HEAD-TAIL'); + }); + + conformanceTest(context, 'concurrent resourceWrite appends preserve every write', async function () { + await initializeClient('resource-append-concurrent'); + const root = createWorkspace('ahp-resource-append-concurrent-'); + const file = fileUri(root, 'append.txt'); + writeFileSync(join(root, 'append.txt'), ''); + const pieces = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']; + + await Promise.all(pieces.map(piece => writeText(file, piece, { mode: ResourceWriteMode.Append }))); + + assert.deepStrictEqual([...readFileSync(join(root, 'append.txt'), 'utf8')].sort(), pieces); + }); + conformanceTest(context, 'resourceWrite accepts the current etag', async function () { await initializeClient('resource-if-match-current'); const root = createWorkspace('ahp-resource-if-match-current-'); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts index c659373f6a686c..8a0c18181121f8 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts @@ -71,6 +71,22 @@ export function defineMultiChatTests(context: IAgentHostE2ETestContext): void { return result.snapshot!.state as ChatState; } + async function chatCompletions(chatUri: string, text: string): Promise { + return context.client.call('completions', { + channel: chatUri, + kind: CompletionItemKind.UserMessage, + text, + offset: text.length, + }); + } + + async function createCompletedPeer(sessionUri: string, id: string, title: string, turnId = `turn-${id}`): Promise { + const peer = await createPeer(sessionUri, id); + await context.client.call('subscribe', { channel: peer }); + await driveTurn(peer, turnId, `/rename ${title}`, 1); + return peer; + } + async function rename(channel: string, title: string, clientSeq = 1): Promise { context.client.clearReceived(); context.client.dispatch({ @@ -394,6 +410,130 @@ export function defineMultiChatTests(context: IAgentHostE2ETestContext): void { assert.deepStrictEqual(completions.items.map(item => item.insertText), ['@peer-target.txt']); }, config.supportsMultipleChats); + conformanceTest(context, 'chat completion references a peer at its last completed turn', async function () { + const { sessionUri, defaultChatUri } = await createSession('chat-completion-basic'); + const peer = await createCompletedPeer(sessionUri, 'peer', 'Referenced Peer', 'peer-reference-turn'); + + const completions = await chatCompletions(defaultChatUri, '#chat:'); + + assert.deepStrictEqual(completions.items, [{ + insertText: '#chat:Referenced Peer ', + rangeStart: 0, + rangeEnd: 6, + attachment: { + type: MessageAttachmentKind.Chat, + resource: peer, + endTurn: 'peer-reference-turn', + label: 'Referenced Peer', + }, + }]); + }, config.supportsMultipleChats); + + conformanceTest(context, 'chat completion filters peer titles case-insensitively', async function () { + const { sessionUri, defaultChatUri } = await createSession('chat-completion-filter'); + const target = await createCompletedPeer(sessionUri, 'target', 'Alpha Target'); + await createCompletedPeer(sessionUri, 'other', 'Beta Reference'); + + const completions = await chatCompletions(defaultChatUri, '#chat:tArGeT'); + + assert.deepStrictEqual(completions.items.map(item => item.attachment), [{ + type: MessageAttachmentKind.Chat, + resource: target, + endTurn: 'turn-target', + label: 'Alpha Target', + }]); + }, config.supportsMultipleChats); + + conformanceTest(context, 'partial chat prefix offers completed peer chats', async function () { + const { sessionUri, defaultChatUri } = await createSession('chat-completion-partial'); + await createCompletedPeer(sessionUri, 'peer', 'Partial Peer'); + + const completions = await chatCompletions(defaultChatUri, '#ch'); + + assert.deepStrictEqual(completions.items.map(item => ({ + insertText: item.insertText, + rangeStart: item.rangeStart, + rangeEnd: item.rangeEnd, + })), [{ + insertText: '#chat:Partial Peer ', + rangeStart: 0, + rangeEnd: 3, + }]); + }, config.supportsMultipleChats); + + conformanceTest(context, 'chat completion replaces only the whitespace-delimited token', async function () { + const { sessionUri, defaultChatUri } = await createSession('chat-completion-range'); + await createCompletedPeer(sessionUri, 'peer', 'Range Peer'); + const text = 'Compare with #chat:Range'; + + const completions = await chatCompletions(defaultChatUri, text); + + assert.deepStrictEqual(completions.items.map(item => ({ + insertText: item.insertText, + rangeStart: item.rangeStart, + rangeEnd: item.rangeEnd, + })), [{ + insertText: '#chat:Range Peer ', + rangeStart: 'Compare with '.length, + rangeEnd: text.length, + }]); + }, config.supportsMultipleChats); + + conformanceTest(context, 'chat completion excludes peers without a completed turn', async function () { + const { sessionUri, defaultChatUri } = await createSession('chat-completion-empty'); + await createPeer(sessionUri, 'empty'); + const completed = await createCompletedPeer(sessionUri, 'completed', 'Completed Peer'); + + const completions = await chatCompletions(defaultChatUri, '#chat:'); + + assert.deepStrictEqual(completions.items.map(item => + item.attachment?.type === MessageAttachmentKind.Chat ? item.attachment.resource : undefined + ), [completed]); + }, config.supportsMultipleChats); + + conformanceTest(context, 'chat completion excludes the chat being edited', async function () { + const { sessionUri } = await createSession('chat-completion-current'); + const current = await createCompletedPeer(sessionUri, 'current', 'Current Peer'); + const sibling = await createCompletedPeer(sessionUri, 'sibling', 'Sibling Peer'); + + const completions = await chatCompletions(current, '#chat:'); + + assert.deepStrictEqual(completions.items.map(item => + item.attachment?.type === MessageAttachmentKind.Chat ? item.attachment.resource : undefined + ), [sibling]); + }, config.supportsMultipleChats); + + conformanceTest(context, 'peer chat completion can reference the completed default chat', async function () { + const { sessionUri, defaultChatUri } = await createSession('chat-completion-default'); + await driveTurn(defaultChatUri, 'default-reference-turn', '/rename Default Reference', 1); + const peer = await createPeer(sessionUri, 'peer'); + + const completions = await chatCompletions(peer, '#chat:Default'); + + assert.deepStrictEqual(completions.items.map(item => item.attachment), [{ + type: MessageAttachmentKind.Chat, + resource: defaultChatUri, + endTurn: 'default-reference-turn', + label: 'Default Reference', + }]); + }, config.supportsMultipleChats); + + conformanceTest(context, 'chat completion sanitizes multiline titles', async function () { + const { sessionUri, defaultChatUri } = await createSession('chat-completion-title'); + const peer = await createCompletedPeer(sessionUri, 'peer', 'Initial Title'); + await rename(peer, 'Line One\n Line Two', 2); + + const completions = await chatCompletions(defaultChatUri, '#chat:Line'); + + assert.deepStrictEqual(completions.items.map(item => ({ + insertText: item.insertText, + label: item.attachment?.label, + })), [{ + insertText: '#chat:Line One Line Two ', + label: 'Line One Line Two', + }]); + }, config.supportsMultipleChats); + conformanceTest(context, 'first peer chat snapshots the session title onto the default chat', async function () { const { sessionUri, defaultChatUri } = await createSession('default-title'); await rename(sessionUri, 'Original Session'); From 82bf722255859e0b92739839eddd25317c982b60 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Sat, 15 Aug 2026 19:04:06 +0200 Subject: [PATCH 2/8] Avoid blocking agent host startup on proxy resolution (#330876) * Avoid blocking agent host startup on proxy resolution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Defer proxy restart during credential updates Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Refresh proxy on every Copilot client start Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/node/agentHostBootstrap.ts | 2 +- .../agentHost/node/agentHostProxyResolver.ts | 18 +- .../agentHost/node/copilot/copilotAgent.ts | 111 +++-- .../test/node/agentHostRequestService.test.ts | 32 +- .../agentHost/test/node/copilotAgent.test.ts | 429 +++++++++++++++++- 5 files changed, 538 insertions(+), 54 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostBootstrap.ts b/src/vs/platform/agentHost/node/agentHostBootstrap.ts index e32f2c136829c0..3baef2a365984c 100644 --- a/src/vs/platform/agentHost/node/agentHostBootstrap.ts +++ b/src/vs/platform/agentHost/node/agentHostBootstrap.ts @@ -54,7 +54,7 @@ export async function registerAgentHostNetworkServices( const configurationService = disposables.add(new ConfigurationService(settingsResource, fileService, policyService, logService)); await configurationService.initialize(); diServices.set(IConfigurationService, configurationService); - const proxyResolver = new AgentHostProxyResolver(configurationService, logService); + const proxyResolver = disposables.add(new AgentHostProxyResolver(configurationService, logService)); diServices.set(IAgentHostProxyResolver, proxyResolver); const requestService = disposables.add(new AgentHostRequestService(configurationService, environmentService, logService, proxyResolver)); diServices.set(IRequestService, requestService); diff --git a/src/vs/platform/agentHost/node/agentHostProxyResolver.ts b/src/vs/platform/agentHost/node/agentHostProxyResolver.ts index 11dc94ee8379bf..127948f1117397 100644 --- a/src/vs/platform/agentHost/node/agentHostProxyResolver.ts +++ b/src/vs/platform/agentHost/node/agentHostProxyResolver.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { LogLevel as ProxyLogLevel, ProxyAgentParams, ProxySupportSetting, createFetchPatch, createProxyAuthorizationLookup, createProxyResolver, loadSystemCertificates } from '@vscode/proxy-agent'; -import { IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { Disposable, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { ILogService, LogLevel } from '../../log/common/log.js'; @@ -26,6 +27,8 @@ export const IAgentHostProxyResolver = createDecorator( export interface IAgentHostProxyResolver { readonly _serviceBrand: undefined; + readonly onDidRegisterConnection: Event; + /** Register a renderer connection. Disposing the result removes it. */ register(clientId: string, connection: IAgentHostClientProxyConnection): IDisposable; @@ -42,10 +45,13 @@ export interface IAgentHostProxyResolver { fetch(input: string | URL | Request, init?: RequestInit): Promise; } -export class AgentHostProxyResolver implements IAgentHostProxyResolver { +export class AgentHostProxyResolver extends Disposable implements IAgentHostProxyResolver { declare readonly _serviceBrand: undefined; + private readonly _onDidRegisterConnection = this._register(new Emitter()); + readonly onDidRegisterConnection = this._onDidRegisterConnection.event; + private readonly _connections = new Map(); private _proxyResolver: ReturnType | undefined; private _proxyAgentParams: ProxyAgentParams | undefined; @@ -54,10 +60,16 @@ export class AgentHostProxyResolver implements IAgentHostProxyResolver { constructor( @IConfigurationService private readonly _configurationService: IConfigurationService, @ILogService private readonly _logService: ILogService, - ) { } + ) { + super(); + } register(clientId: string, connection: IAgentHostClientProxyConnection): IDisposable { + const hadConnections = this._connections.size > 0; this._connections.set(clientId, connection); + if (!hadConnections) { + this._onDidRegisterConnection.fire(); + } return toDisposable(() => { if (this._connections.get(clientId) === connection) { this._connections.delete(clientId); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index a76508cb4282e7..c97b023a50849b 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -662,11 +662,9 @@ export class CopilotAgent extends Disposable implements IAgent { private _client: CopilotClient | undefined; private _clientStarting: Promise | undefined; private _clientStopping: Promise | undefined; - /** - * Proxy URL injected into the running client's subprocess env (`undefined` - * when none was injected). Used to detect when a token change alters the - * token-discovered CAPI endpoint's proxy so we can restart the client. - */ + private _resolvedProxy: string | undefined; + private _proxyRefresh: Promise | undefined; + private _proxyResolutionGeneration = 0; private _appliedProxy: string | undefined; /** * Reasons for a client restart that is parked until every chat is idle. See @@ -676,6 +674,7 @@ export class CopilotAgent extends Disposable implements IAgent { private _closedConnectionRecovery: { readonly clientFailureId: string; readonly promise: Promise } | undefined; private readonly _reportedClientFailures = new WeakSet(); private readonly _authenticationSequencer = new Sequencer(); + private _updatingGitHubCredentials = false; private _githubToken: string | undefined; private _serverToolHost: IAgentServerToolHost | undefined; @@ -792,6 +791,7 @@ export class CopilotAgent extends Disposable implements IAgent { this._githubTelemetryRouter = isAgentHostTelemetryService(this._telemetryService) ? new AgentHostGitHubTelemetryRouter(this._telemetryService) : undefined; + this._register(this._proxyResolver.onDidRegisterConnection(() => this._refreshProxy())); this.onDidCustomizationsChange = this._plugins.onDidChange; // Mirror host-owned titles under the SDK conversation id used by the agent's turn spans. this._register(sessionTitleSignal.onDidChangeSessionTitle(({ provider, session, title }) => { @@ -939,6 +939,7 @@ export class CopilotAgent extends Disposable implements IAgent { const enterpriseHost = this._getEnterpriseHost(); const systemProxyEnabled = this._isSystemProxyEnabled(); const managedSettingsPermissions = this._managedSettingsService.permissions; + const proxyTargetChanged = this._lastEnterpriseHost !== enterpriseHost || this._lastSystemProxyEnabled !== systemProxyEnabled; if (this._lastSessionSyncEnabled === sessionSync && this._lastRubberDuckEnabled === rubberDuck && this._lastCopilotSdkLogLevelSetting === copilotSdkLogLevelSetting && this._lastEnterpriseHost === enterpriseHost && this._lastSystemProxyEnabled === systemProxyEnabled && equals(this._lastManagedSettingsPermissions, managedSettingsPermissions)) { return; } @@ -956,6 +957,9 @@ export class CopilotAgent extends Disposable implements IAgent { this._lastEnterpriseHost = enterpriseHost; this._lastSystemProxyEnabled = systemProxyEnabled; this._lastManagedSettingsPermissions = managedSettingsPermissions; + if (proxyTargetChanged) { + this._refreshProxy(); + } if (this._client) { this._logService.info(`[Copilot] Startup config changed (${changed}), restarting CopilotClient`); } @@ -991,6 +995,10 @@ export class CopilotAgent extends Disposable implements IAgent { if (!this._client) { return; } + if (this._updatingGitHubCredentials) { + this._logService.info(`[Copilot] Deferring CopilotClient restart (${reason}) until GitHub credential updates finish`); + return; + } const busyChats = this._chatsWithActiveTurn(); if (busyChats > 0) { this._logService.info(`[Copilot] Deferring CopilotClient restart (${reason}) until ${busyChats} in-flight turn(s) finish`); @@ -1005,7 +1013,7 @@ export class CopilotAgent extends Disposable implements IAgent { * to go idle drives this again. */ private async _applyPendingClientRestart(): Promise { - if (this._pendingClientRestartReasons.size === 0 || this._shutdownPromise || !this._client || this._chatsWithActiveTurn() > 0) { + if (this._pendingClientRestartReasons.size === 0 || this._shutdownPromise || !this._client || this._updatingGitHubCredentials || this._chatsWithActiveTurn() > 0) { return; } const reason = [...this._pendingClientRestartReasons].join('; '); @@ -1371,6 +1379,7 @@ export class CopilotAgent extends Disposable implements IAgent { this._logService.info(`[Copilot] Auth token ${token ? 'updated' : 'cleared'}`); this._githubToken = token; this._updateRestrictedTelemetry(token); + this._refreshProxy(); if (!token) { await this._requestClientRestart('GitHub authentication cleared'); void this._scheduleModelRefresh(); @@ -1378,24 +1387,28 @@ export class CopilotAgent extends Disposable implements IAgent { } const host = this._gitHubEndpointService.getEnterpriseUri() ?? 'https://github.com'; let restartRequired = false; - for (const session of this._allLiveSessions()) { - try { - const result = await session.updateGitHubCredentials(host, token); - if (!result.success) { + this._updatingGitHubCredentials = true; + try { + for (const session of this._allLiveSessions()) { + try { + const result = await session.updateGitHubCredentials(host, token); + if (!result.success) { + restartRequired = true; + this._logService.warn(`[Copilot:${session.sessionId}] GitHub credential update was rejected; scheduling a safe CopilotClient restart`); + } else if (result.copilotUserResolved === false) { + this._logService.warn(`[Copilot:${session.sessionId}] GitHub credentials were updated, but Copilot user metadata could not be resolved; plan, quota, and billing metadata may be degraded. Reauthenticate to restore it.`); + } + } catch (error) { restartRequired = true; - this._logService.warn(`[Copilot:${session.sessionId}] GitHub credential update was rejected; scheduling a safe CopilotClient restart`); - } else if (result.copilotUserResolved === false) { - this._logService.warn(`[Copilot:${session.sessionId}] GitHub credentials were updated, but Copilot user metadata could not be resolved; plan, quota, and billing metadata may be degraded. Reauthenticate to restore it.`); + this._logService.warn(`[Copilot:${session.sessionId}] Failed to update GitHub credentials; scheduling a safe CopilotClient restart: ${getErrorMessage(error)}`); } - } catch (error) { - restartRequired = true; - this._logService.warn(`[Copilot:${session.sessionId}] Failed to update GitHub credentials; scheduling a safe CopilotClient restart: ${getErrorMessage(error)}`); } + } finally { + this._updatingGitHubCredentials = false; + await this._applyPendingClientRestart(); } if (restartRequired) { await this._requestClientRestart('GitHub credential update failed'); - } else { - await this._restartClientIfProxyChanged(); } await this._resolveCopilotSku(token); void this._scheduleModelRefresh(); @@ -1742,6 +1755,9 @@ export class CopilotAgent extends Disposable implements IAgent { if (this._clientStarting) { return this._clientStarting; } + if (!this._proxyRefresh) { + this._refreshProxy(); + } // Snapshot the startup config so we can detect a change that lands while the // client is still starting and abort the stale start (the values are baked // into the client options / subprocess env below). @@ -1760,7 +1776,7 @@ export class CopilotAgent extends Disposable implements IAgent { // deliberately never reach the runtime; an ambient value here would // re-introduce a process-wide alias for every session behind its back. delete env['COPILOT_MODEL_FAMILY']; - await this._configureProxyEnv(env); + this._applyProxyEnv(env); // On Linux the MXC bubblewrap sandbox backend does not forward a PTY into // the container, so the CLI's default PTY-backed interactive shell can @@ -3996,8 +4012,8 @@ export class CopilotAgent extends Disposable implements IAgent { // ---- helpers ------------------------------------------------------------ - private async _configureProxyEnv(env: Record): Promise { - const proxy = await this._resolveProxyForSdk(env); + private _applyProxyEnv(env: Record): void { + const proxy = this._isSystemProxyEnabled() ? this._resolvedProxy : undefined; this._appliedProxy = proxy; if (proxy) { for (const key of COPILOT_PROXY_SET_ENV_KEYS) { @@ -4036,31 +4052,38 @@ export class CopilotAgent extends Disposable implements IAgent { } } - /** - * Restarts the client when token-based CAPI endpoint discovery changes its - * subprocess proxy. Session credential updates otherwise keep the process alive. - */ - private async _restartClientIfProxyChanged(): Promise { - if (!this._client && !this._clientStarting) { - return; - } - const oldProxy = this._appliedProxy; - const newProxy = await this._resolveProxyForSdk(); - if (newProxy === oldProxy) { - return; - } - if (this._clientStarting) { - try { - await this._clientStarting; - } catch { + private _refreshProxy(): void { + const generation = ++this._proxyResolutionGeneration; + const refresh = this._resolveProxyForSdk().then(async proxy => { + if (generation !== this._proxyResolutionGeneration) { return; } - } - if (!this._client) { - return; - } - this._logService.info(`[Copilot] CAPI proxy changed after token update (${oldProxy ?? '(none)'} -> ${newProxy ?? '(none)'}); restarting CopilotClient`); - await this._requestClientRestart('CAPI proxy changed after GitHub token update'); + this._resolvedProxy = proxy; + const effectiveProxy = this._isSystemProxyEnabled() ? proxy : undefined; + if (effectiveProxy === this._appliedProxy) { + return; + } + if (this._clientStarting) { + try { + await this._clientStarting; + } catch { + return; + } + // A newer proxy resolution (or the client start we just awaited) + // may have already superseded this one; re-check both so we don't + // restart based on a stale comparison. + if (generation !== this._proxyResolutionGeneration || effectiveProxy === this._appliedProxy) { + return; + } + } + await this._requestClientRestart(`CAPI proxy changed (${this._appliedProxy ?? '(none)'} -> ${effectiveProxy ?? '(none)'})`); + }).catch(error => this._logService.error('[Copilot] Failed to refresh CAPI proxy', error)); + this._proxyRefresh = refresh; + void refresh.finally(() => { + if (this._proxyRefresh === refresh) { + this._proxyRefresh = undefined; + } + }); } private _getOrCreateActiveClient(session: URI, directory: URI | undefined): ActiveClient { diff --git a/src/vs/platform/agentHost/test/node/agentHostRequestService.test.ts b/src/vs/platform/agentHost/test/node/agentHostRequestService.test.ts index e8eef9cf8b7d56..37c2a43be888d0 100644 --- a/src/vs/platform/agentHost/test/node/agentHostRequestService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostRequestService.test.ts @@ -17,12 +17,13 @@ import { NullLogService } from '../../../log/common/log.js'; import { IProductService } from '../../../product/common/productService.js'; import { AuthInfo, IRequestService } from '../../../request/common/request.js'; import { AgentHostClientProxyChannel, createAgentHostClientProxyConnection, type IAgentHostClientProxyConnection } from '../../common/agentHostClientProxyChannel.js'; -import { IAgentHostProxyResolver } from '../../node/agentHostProxyResolver.js'; +import { AgentHostProxyResolver, IAgentHostProxyResolver } from '../../node/agentHostProxyResolver.js'; import { AgentHostRequestService } from '../../node/agentHostRequestService.js'; import { NetworkDiagnosticsService } from '../../node/networkDiagnosticsService.js'; class TestProxyResolver implements IAgentHostProxyResolver { declare readonly _serviceBrand: undefined; + readonly onDidRegisterConnection = Event.None; lastInput: string | URL | Request | undefined; lastInit: RequestInit | undefined; @@ -43,6 +44,35 @@ class TestProxyResolver implements IAgentHostProxyResolver { } } +suite('AgentHostProxyResolver', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('fires when the first connection registers and after all connections reconnect', () => { + const resolver = disposables.add(new AgentHostProxyResolver(new TestConfigurationService(), new NullLogService())); + let registrations = 0; + disposables.add(resolver.onDidRegisterConnection(() => registrations++)); + const connection: IAgentHostClientProxyConnection = { + resolveProxy: async () => undefined, + lookupAuthorization: async () => undefined, + lookupKerberosAuthorization: async () => undefined, + }; + + const first = disposables.add(resolver.register('first', connection)); + const afterFirst = registrations; + const second = disposables.add(resolver.register('second', connection)); + const afterSecond = registrations; + first.dispose(); + second.dispose(); + disposables.add(resolver.register('third', connection)); + + assert.deepStrictEqual({ afterFirst, afterSecond, afterReconnect: registrations }, { + afterFirst: 1, + afterSecond: 1, + afterReconnect: 2, + }); + }); +}); + suite('AgentHostRequestService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 272450ff57f23a..4d631f99de0ac3 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -13,7 +13,7 @@ import * as os from 'os'; import { VSBuffer } from '../../../../base/common/buffer.js'; import { DeferredPromise, timeout } from '../../../../base/common/async.js'; import { isCancellationError } from '../../../../base/common/errors.js'; -import { Disposable, type DisposableStore, type IDisposable, type IReference } from '../../../../base/common/lifecycle.js'; +import { Disposable, toDisposable, type DisposableStore, type IDisposable, type IReference } from '../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Schemas } from '../../../../base/common/network.js'; import { autorun, observableValue, waitForState } from '../../../../base/common/observable.js'; @@ -670,15 +670,29 @@ class RecordingReleaseOTelService implements IAgentHostOTelService { class TestProxyResolver implements IAgentHostProxyResolver { declare readonly _serviceBrand: undefined; + private readonly _onDidRegisterConnection = new Emitter(); + readonly onDidRegisterConnection = this._onDidRegisterConnection.event; + private readonly _connections = new Map(); resolveProxyCalls = 0; resolvedProxy: string | undefined; + resolveProxyGate: Promise | undefined; - register(_clientId: string, _connection: IAgentHostClientProxyConnection): IDisposable { - return Disposable.None; + register(clientId: string, connection: IAgentHostClientProxyConnection): IDisposable { + const hadConnections = this._connections.size > 0; + this._connections.set(clientId, connection); + if (!hadConnections) { + this._onDidRegisterConnection.fire(); + } + return toDisposable(() => { + if (this._connections.get(clientId) === connection) { + this._connections.delete(clientId); + } + }); } async resolveProxy(_url: string): Promise { this.resolveProxyCalls++; + await this.resolveProxyGate; return this.resolvedProxy; } @@ -695,6 +709,7 @@ class ResumePathCopilotAgent extends CopilotAgent { @IAgentConfigurationService configurationService: IAgentConfigurationService, @IAgentHostSessionTitleSignal sessionTitleSignal: IAgentHostSessionTitleSignal, @IAgentHostManagedSettingsService managedSettingsService: IAgentHostManagedSettingsService, + @IAgentHostGitHubEndpointService gitHubEndpointService: IAgentHostGitHubEndpointService, @IAgentHostOTelService otelService: IAgentHostOTelService, @IAgentHostCompletions completions: IAgentHostCompletions, @IAgentHostCustomizationEnablementService customizationEnablementService: ICustomizationEnablementService, @@ -704,7 +719,7 @@ class ResumePathCopilotAgent extends CopilotAgent { @IAgentHostProxyResolver proxyResolver: IAgentHostProxyResolver, @ICopilotApiService copilotApiService: ICopilotApiService, ) { - super(logService, instantiationService, sessionDataService, gitService, configurationService, sessionTitleSignal, managedSettingsService, createTestGitHubEndpointService(), otelService, completions, NULL_CHECKPOINT_SERVICE, NULL_REVIEW_SERVICE, customizationEnablementService, environmentService, byokBridgeRegistry, telemetryService, copilotApiService, proxyResolver); + super(logService, instantiationService, sessionDataService, gitService, configurationService, sessionTitleSignal, managedSettingsService, gitHubEndpointService, otelService, completions, NULL_CHECKPOINT_SERVICE, NULL_REVIEW_SERVICE, customizationEnablementService, environmentService, byokBridgeRegistry, telemetryService, copilotApiService, proxyResolver); } protected override _createCopilotClient(): CopilotClient { @@ -731,6 +746,7 @@ class TestableCopilotAgent extends CopilotAgent { @IAgentConfigurationService configurationService: IAgentConfigurationService, @IAgentHostSessionTitleSignal sessionTitleSignal: IAgentHostSessionTitleSignal, @IAgentHostManagedSettingsService managedSettingsService: IAgentHostManagedSettingsService, + @IAgentHostGitHubEndpointService gitHubEndpointService: IAgentHostGitHubEndpointService, @IAgentHostOTelService otelService: IAgentHostOTelService, @IAgentHostCompletions completions: IAgentHostCompletions, @IAgentHostCustomizationEnablementService customizationEnablementService: ICustomizationEnablementService, @@ -740,7 +756,7 @@ class TestableCopilotAgent extends CopilotAgent { @IAgentHostProxyResolver proxyResolver: IAgentHostProxyResolver, @ICopilotApiService copilotApiService: ICopilotApiService, ) { - super(logService, instantiationService, sessionDataService, gitService, configurationService, sessionTitleSignal, managedSettingsService, createTestGitHubEndpointService(), otelService, completions, NULL_CHECKPOINT_SERVICE, NULL_REVIEW_SERVICE, customizationEnablementService, environmentService, byokBridgeRegistry, telemetryService, copilotApiService, proxyResolver); + super(logService, instantiationService, sessionDataService, gitService, configurationService, sessionTitleSignal, managedSettingsService, gitHubEndpointService, otelService, completions, NULL_CHECKPOINT_SERVICE, NULL_REVIEW_SERVICE, customizationEnablementService, environmentService, byokBridgeRegistry, telemetryService, copilotApiService, proxyResolver); } protected override _createCopilotClient(options: CopilotClientOptions): CopilotClient { @@ -1803,6 +1819,75 @@ suite('CopilotAgent', () => { } }); + test('defers a proxy-change restart until credential updates finish', async () => { + const client = new TestCopilotClient([]); + const proxyResolver = new TestProxyResolver(); + const proxyResolutionGate = new DeferredPromise(); + const credentialUpdateStarted = new DeferredPromise(); + const credentialUpdateGate = new DeferredPromise(); + const agent = createTestAgent(disposables, { copilotClient: client, proxyResolver }); + const session = { + hasActiveTurn: false, + disposed: false, + disposedBeforeUpdateCompleted: false, + async updateGitHubCredentials() { + credentialUpdateStarted.complete(); + await credentialUpdateGate.p; + this.disposedBeforeUpdateCompleted = this.disposed; + return { success: true }; + }, + dispose() { this.disposed = true; }, + } satisfies ICredentialUpdateSession & { disposed: boolean; disposedBeforeUpdateCompleted: boolean }; + const pendingRestartCount = () => (agent as unknown as { _pendingClientRestartReasons: Set })._pendingClientRestartReasons.size; + try { + await agent.listChatsToMigrate(); + setDefaultSessionStub(agent, 'proxy-change-during-credentials', session); + proxyResolver.resolvedProxy = 'http://new-proxy:8080'; + proxyResolver.resolveProxyGate = proxyResolutionGate.p; + + const authentication = agent.authenticate('https://api.github.com', 'fresh-token'); + await credentialUpdateStarted.p; + proxyResolutionGate.complete(); + for (let i = 0; i < 20 && pendingRestartCount() === 0 && client.stopCallCount === 0; i++) { + await timeout(0); + } + const duringUpdate = { + stops: client.stopCallCount, + disposed: session.disposed, + pendingRestarts: pendingRestartCount(), + }; + + credentialUpdateGate.complete(); + await authentication; + + assert.deepStrictEqual({ + duringUpdate, + disposedBeforeUpdateCompleted: session.disposedBeforeUpdateCompleted, + afterUpdate: { + stops: client.stopCallCount, + disposed: session.disposed, + pendingRestarts: pendingRestartCount(), + }, + }, { + duringUpdate: { + stops: 0, + disposed: false, + pendingRestarts: 1, + }, + disposedBeforeUpdateCompleted: false, + afterUpdate: { + stops: 1, + disposed: true, + pendingRestarts: 0, + }, + }); + } finally { + proxyResolutionGate.complete(); + credentialUpdateGate.complete(); + await disposeAgent(agent); + } + }); + test('defers a proxy-change restart until an active turn ends', async () => { const client = new TestCopilotClient([]); const proxyResolver = new TestProxyResolver(); @@ -2772,6 +2857,340 @@ suite('CopilotAgent', () => { } }); + test('does not block client startup on system proxy resolution', async () => { + const client = new TestCopilotClient([]); + const proxyResolver = new TestProxyResolver(); + const resolveProxyGate = new DeferredPromise(); + proxyResolver.resolvedProxy = 'http://system-proxy.example:8080'; + proxyResolver.resolveProxyGate = resolveProxyGate.p; + const { agent } = createTestAgentContext(disposables, { copilotClient: client, proxyResolver }); + const startup = agent.listChatsToMigrate(); + let proxyResolutionCompleted = false; + try { + await startup; + + assert.deepStrictEqual({ + startCallCount: client.startCallCount, + resolveProxyCalls: proxyResolver.resolveProxyCalls, + httpProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTP_PROXY'], + }, { + startCallCount: 1, + resolveProxyCalls: 1, + httpProxy: undefined, + }); + + resolveProxyGate.complete(); + proxyResolutionCompleted = true; + for (let i = 0; i < 20 && client.stopCallCount < 1; i++) { + await timeout(0); + } + await agent.listChatsToMigrate(); + + assert.deepStrictEqual({ + startCallCount: client.startCallCount, + stopCallCount: client.stopCallCount, + resolveProxyCalls: proxyResolver.resolveProxyCalls, + httpProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTP_PROXY'], + httpsProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTPS_PROXY'], + }, { + startCallCount: 2, + stopCallCount: 1, + resolveProxyCalls: 2, + httpProxy: proxyResolver.resolvedProxy, + httpsProxy: proxyResolver.resolvedProxy, + }); + } finally { + if (!proxyResolutionCompleted) { + resolveProxyGate.complete(); + } + await startup; + await disposeAgent(agent); + } + }); + + test('does not restart for a proxy resolution superseded while the client starts', async () => { + const client = new TestCopilotClient([]); + const proxyResolver = new TestProxyResolver(); + const resolveProxyGate = new DeferredPromise(); + const startGate = new DeferredPromise(); + const firstProxy = 'http://stale-system-proxy.example:8080'; + proxyResolver.resolvedProxy = firstProxy; + proxyResolver.resolveProxyGate = resolveProxyGate.p; + client.startGate = startGate.p; + const { agent } = createTestAgentContext(disposables, { copilotClient: client, proxyResolver }); + const proxyState = agent as unknown as { + _resolvedProxy: string | undefined; + _refreshProxy(): void; + }; + const startup = agent.listChatsToMigrate(); + try { + for (let i = 0; i < 20 && client.startCallCount < 1; i++) { + await timeout(0); + } + assert.deepStrictEqual({ + startCallCount: client.startCallCount, + resolveProxyCalls: proxyResolver.resolveProxyCalls, + }, { + startCallCount: 1, + resolveProxyCalls: 1, + }); + + resolveProxyGate.complete(); + for (let i = 0; i < 20 && proxyState._resolvedProxy !== firstProxy; i++) { + await timeout(0); + } + assert.strictEqual(proxyState._resolvedProxy, firstProxy); + + proxyResolver.resolvedProxy = undefined; + proxyResolver.resolveProxyGate = undefined; + proxyState._refreshProxy(); + for (let i = 0; i < 20 && proxyState._resolvedProxy !== undefined; i++) { + await timeout(0); + } + assert.strictEqual(proxyState._resolvedProxy, undefined); + + startGate.complete(); + await startup; + await timeout(0); + + assert.deepStrictEqual({ + startCallCount: client.startCallCount, + stopCallCount: client.stopCallCount, + resolveProxyCalls: proxyResolver.resolveProxyCalls, + }, { + startCallCount: 1, + stopCallCount: 0, + resolveProxyCalls: 2, + }); + } finally { + resolveProxyGate.complete(); + startGate.complete(); + await startup; + await disposeAgent(agent); + } + }); + + test('forwards a system proxy resolved when the bridge registers before client startup', async () => { + const client = new TestCopilotClient([]); + const proxyResolver = new TestProxyResolver(); + proxyResolver.resolvedProxy = 'http://system-proxy.example:8080'; + const { agent } = createTestAgentContext(disposables, { copilotClient: client, proxyResolver }); + try { + disposables.add(proxyResolver.register('test', { + resolveProxy: async () => undefined, + lookupAuthorization: async () => undefined, + lookupKerberosAuthorization: async () => undefined, + })); + await timeout(0); + await agent.listChatsToMigrate(); + + assert.deepStrictEqual({ + startCallCount: client.startCallCount, + resolveProxyCalls: proxyResolver.resolveProxyCalls, + httpProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTP_PROXY'], + httpsProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTPS_PROXY'], + }, { + startCallCount: 1, + resolveProxyCalls: 2, + httpProxy: proxyResolver.resolvedProxy, + httpsProxy: proxyResolver.resolvedProxy, + }); + } finally { + await disposeAgent(agent); + } + }); + + test('resolves the proxy when system proxy support is enabled after construction', async () => { + const client = new TestCopilotClient([]); + const proxyResolver = new TestProxyResolver(); + const proxy = 'http://enabled-system-proxy.example:8080'; + const { agent, configurationService } = createTestAgentContext(disposables, { + copilotClient: client, + proxyResolver, + rootConfig: { [AgentHostSystemProxyEnabledConfigKey]: false }, + }); + try { + await agent.listChatsToMigrate(); + proxyResolver.resolvedProxy = proxy; + configurationService.updateRootConfig({ [AgentHostSystemProxyEnabledConfigKey]: true }); + for (let i = 0; i < 20 && client.stopCallCount < 1; i++) { + await timeout(0); + } + await agent.listChatsToMigrate(); + + assert.deepStrictEqual({ + startCallCount: client.startCallCount, + stopCallCount: client.stopCallCount, + resolveProxyCalls: proxyResolver.resolveProxyCalls, + httpProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTP_PROXY'], + httpsProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTPS_PROXY'], + }, { + startCallCount: 2, + stopCallCount: 1, + resolveProxyCalls: 1, + httpProxy: proxy, + httpsProxy: proxy, + }); + } finally { + await disposeAgent(agent); + } + }); + + test('refreshes the proxy when the enterprise host changes', async () => { + const client = new TestCopilotClient([]); + const proxyResolver = new TestProxyResolver(); + const endpointChange = disposables.add(new Emitter()); + let enterpriseUri: string | undefined; + const currentEndpointService = () => createTestGitHubEndpointService(enterpriseUri); + const endpointService = { + _serviceBrand: undefined, + onDidChange: endpointChange.event, + getApiBaseUri: () => currentEndpointService().getApiBaseUri(), + getGraphQlUri: () => currentEndpointService().getGraphQlUri(), + getEnterpriseHost: () => currentEndpointService().getEnterpriseHost(), + getEnterpriseUri: () => currentEndpointService().getEnterpriseUri(), + getCopilotResource: () => currentEndpointService().getCopilotResource(), + getRepoResource: () => currentEndpointService().getRepoResource(), + } satisfies IAgentHostGitHubEndpointService; + const initialProxy = 'http://github-proxy.example:8080'; + const enterpriseProxy = 'http://enterprise-proxy.example:8080'; + proxyResolver.resolvedProxy = initialProxy; + const { agent } = createTestAgentContext(disposables, { copilotClient: client, proxyResolver, gitHubEndpointService: endpointService }); + try { + disposables.add(proxyResolver.register('test', { + resolveProxy: async () => undefined, + lookupAuthorization: async () => undefined, + lookupKerberosAuthorization: async () => undefined, + })); + await timeout(0); + await agent.listChatsToMigrate(); + proxyResolver.resolvedProxy = enterpriseProxy; + enterpriseUri = 'https://github.example.com'; + endpointChange.fire(); + for (let i = 0; i < 20 && client.stopCallCount < 1; i++) { + await timeout(0); + } + await agent.listChatsToMigrate(); + + assert.deepStrictEqual({ + startCallCount: client.startCallCount, + stopCallCount: client.stopCallCount, + resolveProxyCalls: proxyResolver.resolveProxyCalls, + httpProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTP_PROXY'], + httpsProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTPS_PROXY'], + }, { + startCallCount: 2, + stopCallCount: 1, + resolveProxyCalls: 3, + httpProxy: enterpriseProxy, + httpsProxy: enterpriseProxy, + }); + } finally { + await disposeAgent(agent); + } + }); + + test('resolves the proxy on first client start without a bridge', async () => { + const client = new TestCopilotClient([]); + const proxyResolver = new TestProxyResolver(); + const proxy = 'http://late-system-proxy.example:8080'; + const { agent } = createTestAgentContext(disposables, { copilotClient: client, proxyResolver }); + try { + await timeout(0); + proxyResolver.resolvedProxy = proxy; + await agent.listChatsToMigrate(); + for (let i = 0; i < 20 && client.stopCallCount < 1; i++) { + await timeout(0); + } + await agent.listChatsToMigrate(); + + assert.deepStrictEqual({ + startCallCount: client.startCallCount, + stopCallCount: client.stopCallCount, + resolveProxyCalls: proxyResolver.resolveProxyCalls, + httpProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTP_PROXY'], + httpsProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTPS_PROXY'], + }, { + startCallCount: 2, + stopCallCount: 1, + resolveProxyCalls: 1, + httpProxy: proxy, + httpsProxy: proxy, + }); + } finally { + await disposeAgent(agent); + } + }); + + test('refreshes the cached proxy without blocking every fresh client start', async () => { + const client = new TestCopilotClient([]); + const proxyResolver = new TestProxyResolver(); + const secondResolutionGate = new DeferredPromise(); + const initialProxy = 'http://initial-system-proxy.example:8080'; + const changedProxy = 'http://changed-system-proxy.example:8080'; + proxyResolver.resolvedProxy = initialProxy; + const { agent } = createTestAgentContext(disposables, { copilotClient: client, proxyResolver }); + const stopClient = () => (agent as unknown as { _stopClient(): Promise })._stopClient(); + try { + disposables.add(proxyResolver.register('test', { + resolveProxy: async () => undefined, + lookupAuthorization: async () => undefined, + lookupKerberosAuthorization: async () => undefined, + })); + await timeout(0); + await agent.listChatsToMigrate(); + await stopClient(); + + proxyResolver.resolvedProxy = changedProxy; + proxyResolver.resolveProxyGate = secondResolutionGate.p; + await agent.listChatsToMigrate(); + const duringResolution = { + startCallCount: client.startCallCount, + stopCallCount: client.stopCallCount, + resolveProxyCalls: proxyResolver.resolveProxyCalls, + httpProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTP_PROXY'], + httpsProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTPS_PROXY'], + }; + + secondResolutionGate.complete(); + for (let i = 0; i < 20 && client.stopCallCount < 2; i++) { + await timeout(0); + } + proxyResolver.resolveProxyGate = undefined; + await agent.listChatsToMigrate(); + await timeout(0); + + assert.deepStrictEqual({ + duringResolution, + afterResolution: { + startCallCount: client.startCallCount, + stopCallCount: client.stopCallCount, + resolveProxyCalls: proxyResolver.resolveProxyCalls, + httpProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTP_PROXY'], + httpsProxy: getCreatedClientOptions(agent).at(-1)?.env?.['HTTPS_PROXY'], + }, + }, { + duringResolution: { + startCallCount: 2, + stopCallCount: 1, + resolveProxyCalls: 3, + httpProxy: initialProxy, + httpsProxy: initialProxy, + }, + afterResolution: { + startCallCount: 3, + stopCallCount: 2, + resolveProxyCalls: 4, + httpProxy: changedProxy, + httpsProxy: changedProxy, + }, + }); + } finally { + secondResolutionGate.complete(); + await disposeAgent(agent); + } + }); + test('passes the configured log level to the Copilot SDK client', async () => { const client = new TestCopilotClient([]); const { agent, configurationService } = createTestAgentContext(disposables, { copilotClient: client }); From 6604ee82711cde1be05c03c11f7234b5411f3a1a Mon Sep 17 00:00:00 2001 From: roblourens Date: Sat, 15 Aug 2026 13:23:16 -0700 Subject: [PATCH 3/8] agentHost: resolve archived divergent Copilot backings (#331032) * agentHost: resolve archived divergent Copilot backings Fixes #331030\n\n(Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: persist resolved resume roots Keep the resolved primary directory aligned with the ordered working-directory metadata when restoring divergent Copilot backings.\n\n(Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/node/copilot/copilotAgent.ts | 15 +++-- .../agentHost/test/node/copilotAgent.test.ts | 59 +++++++++++++++++++ 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index c97b023a50849b..c7115650cb11af 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -3800,15 +3800,20 @@ export class CopilotAgent extends Disposable implements IAgent { return undefined; } const parentEntry = this._findSessionBySdkId(configurationId); - const workingDirectory = workingDirectories?.[0] ?? parentEntry?.workingDirectory + const persistedWorkingDirectory = workingDirectories?.[0] ?? parentEntry?.workingDirectory ?? this._provisionalSessions.get(configurationId)?.workingDirectory ?? (await this._readSessionMetadata(configurationResource)).workingDirectory; - if (!workingDirectory) { + if (!persistedWorkingDirectory) { this._logService.warn(`[Copilot] Cannot resume chat ${chatKey}: missing working directory`); return undefined; } + const workingDirectory = await this._configurationService.resolveWorkingDirectoryForResume(configurationResource.toString(), persistedWorkingDirectory); + const launchWorkingDirectories = workingDirectories + ? [workingDirectory, ...workingDirectories.slice(1)] + : undefined; const client = await this._ensureClient(); const activeClient = this._getOrCreateActiveClient(configurationResource, workingDirectory); + activeClient.pluginController.reanchor(workingDirectory); const snapshot = await activeClient.snapshot(chatKey); const shellManager = this._instantiationService.createInstance(ShellManager, chat, workingDirectory); const launchPlan: CopilotSessionLaunchPlan = { @@ -3816,7 +3821,7 @@ export class CopilotAgent extends Disposable implements IAgent { client, sessionId: info.sdkSessionId, workingDirectory, - additionalDirectories: workingDirectories?.slice(1), + additionalDirectories: launchWorkingDirectories?.slice(1), resolvedAgentName: info.agent ? this._resolveAgentName(snapshot, info.agent) : undefined, snapshot, disabledRootMcpServers: this._disabledRootMcpServers(configurationResource, info.sdkSessionId, snapshot), @@ -3829,8 +3834,8 @@ export class CopilotAgent extends Disposable implements IAgent { await agentSession.initializeSession(); this._throwIfClientReplaced(client, agentSession); this._registerLiveChat(chat, agentSession, activeClient); - if (workingDirectories) { - await this._storeSessionMetadata(context.resource, info.model, workingDirectory, workingDirectories, undefined, undefined); + if (launchWorkingDirectories) { + await this._storeSessionMetadata(context.resource, info.model, workingDirectory, launchWorkingDirectories, undefined, undefined); } this._logService.info(`[Copilot] Resumed chat backing ${chatKey} for configuration ${configurationResource.toString()}`); return agentSession; diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 4d631f99de0ac3..33bff0fade41a5 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -8024,6 +8024,65 @@ suite('CopilotAgent', () => { } }); + test('sendMessage resolves the working directory before resuming an addressed backing', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const { agent, configurationService } = createTestAgentContext(disposables, { + sessionDataService, + copilotClient: new TestCopilotClient([]), + rootConfig: { [AgentHostCopilotMultiRootEnabledConfigKey]: true }, + }); + try { + await agent.authenticate('https://api.github.com', 'token'); + const session = AgentSession.uri('copilotcli', 'route-resolved-peer'); + const chatUri = URI.parse(buildChatUri(session, 'peer-a')); + const persistedWorkingDirectory = URI.file('/missing-worktree'); + const resolvedWorkingDirectory = URI.file('/repository'); + const secondaryWorkingDirectory = URI.file('/secondary'); + const resolveCalls: { session: string; workingDirectory: string }[] = []; + configurationService.resolveWorkingDirectoryForResume = async (session, workingDirectory) => { + resolveCalls.push({ session, workingDirectory: workingDirectory.toString() }); + return resolvedWorkingDirectory; + }; + await provisionSession(agent, { session, workingDirectories: [persistedWorkingDirectory, secondaryWorkingDirectory] }); + await agent.materializeChat(chatUri, session, JSON.stringify({ sdkSessionId: 'peer-sdk-id' })); + + const internals = agent as unknown as ChatInternals; + const launches: { workingDirectory: string | undefined; additionalDirectories: string[] | undefined; customizationDirectory: string | undefined }[] = []; + internals._createAgentSession = (launchPlan, customizationDirectory, _activeClient, identity) => { + launches.push({ + workingDirectory: launchPlan.workingDirectory?.toString(), + additionalDirectories: launchPlan.additionalDirectories?.map(directory => directory.toString()), + customizationDirectory: customizationDirectory?.toString(), + }); + const built = makeFakeChatSession(session, launchPlan.sessionId, undefined, launchPlan.shellManager); + (built.fake as { chatChannelUri?: URI }).chatChannelUri = identity?.chatChannelUri; + (built.fake as { appliedAdditionalDirectories?: readonly URI[] }).appliedAdditionalDirectories = launchPlan.additionalDirectories; + return built.fake; + }; + + await agent.chats.sendMessage(chatUri, 'hello peer', [persistedWorkingDirectory, secondaryWorkingDirectory], undefined, undefined, undefined, exactChatContext(session, chatUri, session)); + const dbRef = sessionDataService.openDatabase(session); + const storedWorkingDirectories = await dbRef.object.getMetadata('copilot.workingDirectories'); + dbRef.dispose(); + + assert.deepStrictEqual({ + resolveCalls, + launches, + storedWorkingDirectories: storedWorkingDirectories ? JSON.parse(storedWorkingDirectories) : undefined, + }, { + resolveCalls: [{ session: session.toString(), workingDirectory: persistedWorkingDirectory.toString() }], + launches: [{ + workingDirectory: resolvedWorkingDirectory.toString(), + additionalDirectories: [secondaryWorkingDirectory.toString()], + customizationDirectory: resolvedWorkingDirectory.toString(), + }], + storedWorkingDirectories: [resolvedWorkingDirectory.toString(), secondaryWorkingDirectory.toString()], + }); + } finally { + await disposeAgent(agent); + } + }); + test('sendMessage throws for a chat with no backing chat', async () => { const agent = createTestAgent(disposables); try { From a88eb324af8dae2f61b1066520a0ba11f0b140c5 Mon Sep 17 00:00:00 2001 From: roblourens Date: Sat, 15 Aug 2026 13:23:33 -0700 Subject: [PATCH 4/8] Fix terminal buffer mark navigation hang (#331035) Consume the existing buffer mark iterator instead of repeatedly creating a new iterator that returns the first marker. Add a regression test for marker navigation without command detection.\n\nFixes #331034\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/xterm/markNavigationAddon.ts | 8 +-- .../browser/xterm/markNavigationAddon.test.ts | 55 +++++++++++++++++++ 2 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 src/vs/workbench/contrib/terminal/test/browser/xterm/markNavigationAddon.test.ts diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/markNavigationAddon.ts b/src/vs/workbench/contrib/terminal/browser/xterm/markNavigationAddon.ts index 523748df685bc1..f3ef66bbe3e920 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/markNavigationAddon.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/markNavigationAddon.ts @@ -76,13 +76,7 @@ export class MarkNavigationAddon extends Disposable implements IMarkTracker, ITe } if (markCapability && !skipEmptyCommands) { - let next = markCapability.markers().next()?.value; - const arr: IMarker[] = []; - while (next) { - arr.push(next); - next = markCapability.markers().next()?.value; - } - markers = arr; + markers = [...markCapability.markers()]; } return markers; } diff --git a/src/vs/workbench/contrib/terminal/test/browser/xterm/markNavigationAddon.test.ts b/src/vs/workbench/contrib/terminal/test/browser/xterm/markNavigationAddon.test.ts new file mode 100644 index 00000000000000..b9870b7c9e9a98 --- /dev/null +++ b/src/vs/workbench/contrib/terminal/test/browser/xterm/markNavigationAddon.test.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { IMarker, Terminal } from '@xterm/xterm'; +import { strictEqual } from 'assert'; +import { importAMDNodeModule } from '../../../../../../amdX.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { TerminalCapability } from '../../../../../../platform/terminal/common/capabilities/capabilities.js'; +import { BufferMarkCapability } from '../../../../../../platform/terminal/common/capabilities/bufferMarkCapability.js'; +import { TerminalCapabilityStore } from '../../../../../../platform/terminal/common/capabilities/terminalCapabilityStore.js'; +import { TestXtermLogger } from '../../../../../../platform/terminal/test/common/terminalTestHelpers.js'; +import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; +import { MarkNavigationAddon } from '../../../browser/xterm/markNavigationAddon.js'; + +class TestBufferMarkCapability extends BufferMarkCapability { + markerIteratorCalls = 0; + + override markers(): IterableIterator { + this.markerIteratorCalls++; + if (this.markerIteratorCalls > 2) { + throw new Error('Buffer mark iterator was requested repeatedly'); + } + return super.markers(); + } +} + +suite('MarkNavigationAddon', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('should consume buffer mark iterators when navigating', async () => { + const TerminalCtor = (await importAMDNodeModule('@xterm/xterm', 'lib/xterm.js')).Terminal; + const terminal: Terminal = store.add(new TerminalCtor({ + allowProposedApi: true, + cols: 80, + rows: 30, + logger: TestXtermLogger + })); + const capabilities = store.add(new TerminalCapabilityStore()); + const markCapability = store.add(new TestBufferMarkCapability(terminal)); + markCapability.addMark(); + capabilities.add(TerminalCapability.BufferMarkDetection, markCapability); + const instantiationService = workbenchInstantiationService({ + configurationService: () => new TestConfigurationService() + }, store); + const addon = store.add(instantiationService.createInstance(MarkNavigationAddon, capabilities)); + terminal.loadAddon(addon); + + addon.scrollToNextMark(undefined, undefined, false); + + strictEqual(markCapability.markerIteratorCalls, 2); + }); +}); From f00de87961f926ebc3838b019c2bf664a72de812 Mon Sep 17 00:00:00 2001 From: roblourens Date: Sat, 15 Aug 2026 13:24:35 -0700 Subject: [PATCH 5/8] [Perf] Add Agent Host startup telemetry (#330484) * Add Agent Host startup telemetry Track connection, authentication, and initial session-list startup phases, including retries, terminal failures, and timeouts. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Attribute post-connection startup failures to the session-list stage A terminal connection close after AHP connected was reported as a protocolConnection failure, matching neither the timeout path nor reality. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../common/agentHostStartupTelemetry.ts | 141 +++++++++++++ .../electron-browser/localAgentHostService.ts | 39 +++- .../localAgentHostService.test.ts | 191 ++++++++++++++++++ 3 files changed, 363 insertions(+), 8 deletions(-) create mode 100644 src/vs/platform/agentHost/common/agentHostStartupTelemetry.ts diff --git a/src/vs/platform/agentHost/common/agentHostStartupTelemetry.ts b/src/vs/platform/agentHost/common/agentHostStartupTelemetry.ts new file mode 100644 index 00000000000000..843ee56014ee32 --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostStartupTelemetry.ts @@ -0,0 +1,141 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, IDisposable } from '../../../base/common/lifecycle.js'; +import { StopWatch } from '../../../base/common/stopwatch.js'; +import { ITelemetryService } from '../../telemetry/common/telemetry.js'; +import { AgentHostClientType } from './agentHostClientInfo.js'; +import { AgentHostClientConnectionKind } from './agentHostTelemetry.js'; + +type AgentHostStartupOutcome = 'success' | 'error' | 'timeout'; +type AgentHostStartupFailureStage = 'protocolConnection' | 'sessionList'; + +export const AgentHostStartupTimeoutMs = 2 * 60 * 1000; + +interface IAgentHostStartupEvent { + clientType: AgentHostClientType; + connectionKind: AgentHostClientConnectionKind; + outcome: AgentHostStartupOutcome; + failureStage: AgentHostStartupFailureStage | undefined; + timeToMessagePortMs: number | undefined; + timeToProtocolConnectionMs: number | undefined; + timeToAuthenticationSettledMs: number | undefined; + timeToSessionListRequestMs: number | undefined; + timeToSessionListCompleteMs: number | undefined; + sessionListDurationMs: number | undefined; + sessionListAttemptCount: number; + sessionListFailureCount: number; +} + +type AgentHostStartupClassification = { + clientType: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The bounded type of the Agent Host client.' }; + connectionKind: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The route the client used to reach the Agent Host.' }; + outcome: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether Agent Host startup reached the first successful session list or failed to connect.' }; + failureStage: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The bounded startup stage that failed, when startup did not succeed.' }; + timeToMessagePortMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Milliseconds from the local Agent Host start request until its initial MessagePort was acquired.' }; + timeToProtocolConnectionMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Milliseconds from the Agent Host start request until AHP initialization completed.' }; + timeToAuthenticationSettledMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Milliseconds from the Agent Host start request until the initial authentication pass settled.' }; + timeToSessionListRequestMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Milliseconds from the Agent Host start request until the first session-list request.' }; + timeToSessionListCompleteMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Milliseconds from the Agent Host start request until the first successful session-list response.' }; + sessionListDurationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Milliseconds from the first session-list request until the first successful response, including retries or overlapping requests.' }; + sessionListAttemptCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Number of session-list requests started before the first successful response or connection failure.' }; + sessionListFailureCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Number of session-list requests that failed before the first successful response or connection failure.' }; + owner: 'roblourens'; + comment: 'Tracks Agent Host startup performance from the client start request through AHP connection, authentication, and the first successful session list.'; +}; + +export class AgentHostStartupTelemetry extends Disposable { + + private readonly _stopWatch; + private readonly _timeout: IDisposable; + private _reported = false; + private _timeToMessagePortMs: number | undefined; + private _timeToProtocolConnectionMs: number | undefined; + private _timeToAuthenticationSettledMs: number | undefined; + private _timeToSessionListRequestMs: number | undefined; + private _sessionListAttemptCount = 0; + private _sessionListFailureCount = 0; + + constructor( + private readonly _clientType: AgentHostClientType, + private readonly _connectionKind: AgentHostClientConnectionKind, + stopWatchFactory: () => Pick, + timeoutFactory: (callback: () => void, timeoutMs: number) => IDisposable, + @ITelemetryService private readonly _telemetryService: ITelemetryService, + ) { + super(); + this._stopWatch = stopWatchFactory(); + this._timeout = this._register(timeoutFactory(() => this._report('timeout', this._failureStage()), AgentHostStartupTimeoutMs)); + } + + messagePortAcquired(): void { + this._timeToMessagePortMs ??= this._stopWatch.elapsed(); + } + + protocolConnected(): void { + this._timeToProtocolConnectionMs ??= this._stopWatch.elapsed(); + } + + authenticationSettled(): void { + this._timeToAuthenticationSettledMs ??= this._stopWatch.elapsed(); + } + + sessionListRequested(): void { + if (this._reported) { + return; + } + this._sessionListAttemptCount++; + this._timeToSessionListRequestMs ??= this._stopWatch.elapsed(); + } + + sessionListFailed(): void { + if (!this._reported) { + this._sessionListFailureCount++; + } + } + + sessionListSucceeded(): void { + this._report('success', undefined); + } + + connectionFailed(): void { + this._report('error', this._failureStage()); + } + + override dispose(): void { + this._reported = true; + super.dispose(); + } + + /** Startup reaches the session-list stage as soon as the protocol connects. */ + private _failureStage(): AgentHostStartupFailureStage { + return this._timeToProtocolConnectionMs === undefined ? 'protocolConnection' : 'sessionList'; + } + + private _report(outcome: AgentHostStartupOutcome, failureStage: AgentHostStartupFailureStage | undefined): void { + if (this._reported) { + return; + } + this._reported = true; + this._timeout.dispose(); + const timeToSessionListCompleteMs = outcome === 'success' ? this._stopWatch.elapsed() : undefined; + this._telemetryService.publicLog2('agentHost.startup', { + clientType: this._clientType, + connectionKind: this._connectionKind, + outcome, + failureStage, + timeToMessagePortMs: this._timeToMessagePortMs, + timeToProtocolConnectionMs: this._timeToProtocolConnectionMs, + timeToAuthenticationSettledMs: this._timeToAuthenticationSettledMs, + timeToSessionListRequestMs: this._timeToSessionListRequestMs, + timeToSessionListCompleteMs, + sessionListDurationMs: timeToSessionListCompleteMs !== undefined && this._timeToSessionListRequestMs !== undefined + ? Math.max(0, timeToSessionListCompleteMs - this._timeToSessionListRequestMs) + : undefined, + sessionListAttemptCount: this._sessionListAttemptCount, + sessionListFailureCount: this._sessionListFailureCount, + }); + } +} diff --git a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts index a349c70544de46..e38f6fc7020317 100644 --- a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts @@ -3,11 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { DeferredPromise } from '../../../base/common/async.js'; +import { DeferredPromise, disposableTimeout } from '../../../base/common/async.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableStore, IReference, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { constObservable, IObservable, ISettableObservable, observableValue } from '../../../base/common/observable.js'; import { mark } from '../../../base/common/performance.js'; +import { StopWatch } from '../../../base/common/stopwatch.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { getDelayedChannel, IChannelClient, IChannelServer, ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; @@ -22,8 +23,10 @@ import { AgentHostIpcChannelTransport } from '../browser/agentHostIpcChannelTran import { AgentHostClientState, RemoteAgentHostProtocolClient } from '../browser/remoteAgentHostProtocolClient.js'; import { AhpJsonlLogger } from '../common/ahpJsonlLogger.js'; import { AGENT_HOST_CLIENT_BYOK_LM_CHANNEL, AgentHostClientByokLmChannel } from '../common/agentHostClientByokLmChannel.js'; +import { getAgentHostClientType } from '../common/agentHostClientInfo.js'; import { AGENT_HOST_CLIENT_PROXY_CHANNEL, AgentHostClientProxyChannel } from '../common/agentHostClientProxyChannel.js'; import { LOCAL_AGENT_HOST_RESOURCE_IDENTITY } from '../common/agentHostResourceService.js'; +import { AgentHostStartupTelemetry } from '../common/agentHostStartupTelemetry.js'; import { AgentHostClientConnectionKind } from '../common/agentHostTelemetry.js'; import { AgentHostAhpJsonlLoggingSettingId, @@ -139,6 +142,7 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos private _didConnectInitially = false; private _didStartInitialSessionList = false; private _didCompleteInitialSessionList = false; + private _startupTelemetry: AgentHostStartupTelemetry | undefined; private readonly _onAgentHostExit = this._register(new Emitter()); readonly onAgentHostExit = this._onAgentHostExit.event; @@ -187,6 +191,13 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos startAgentHost(): void { if (!this._protocolClient) { mark('code/agentHost/willStart'); + this._startupTelemetry = this._register(this._instantiationService.createInstance( + AgentHostStartupTelemetry, + getAgentHostClientType(this._clientInfo), + AgentHostClientConnectionKind.Local, + () => StopWatch.create(true), + (callback, timeoutMs) => disposableTimeout(callback, timeoutMs), + )); this._protocolClient = this._register(this._instantiationService.createInstance( RemoteAgentHostProtocolClient, LOCAL_AGENT_HOST_RESOURCE_IDENTITY, @@ -242,6 +253,7 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos } if (!this._didAcquireInitialMessagePort) { this._didAcquireInitialMessagePort = true; + this._startupTelemetry?.messagePortAcquired(); mark('code/agentHost/didAcquireMessagePort'); } this._logService.info(`${LOG_PREFIX} MessagePort acquired, creating client...`); @@ -273,6 +285,7 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos } if (state === AgentHostClientState.Connected) { this._managementConnection.connected(); + this._startupTelemetry?.protocolConnected(); if (!this._didConnectInitially) { this._didConnectInitially = true; mark('code/agentHost/didConnect'); @@ -284,6 +297,7 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos if (state === AgentHostClientState.Reconnecting) { this._managementConnection.reconnecting(); } else { + this._startupTelemetry?.connectionFailed(); this._managementConnection.closed(state === AgentHostClientState.Incompatible ? 'Local agent host protocol is incompatible.' : 'Local agent host connection closed.'); @@ -305,6 +319,7 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos } if (!pending) { this._authenticationSettled = true; + this._startupTelemetry?.authenticationSettled(); } this._authenticationPending.set(pending, undefined); } @@ -354,17 +369,25 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos } listSessions(): Promise { + this._startupTelemetry?.sessionListRequested(); if (!this._didStartInitialSessionList) { this._didStartInitialSessionList = true; mark('code/agentHost/willListSessions'); } - return this._requireClient().listSessions().then(sessions => { - if (!this._didCompleteInitialSessionList) { - this._didCompleteInitialSessionList = true; - mark('code/agentHost/didListSessions'); - } - return sessions; - }); + return this._requireClient().listSessions().then( + sessions => { + this._startupTelemetry?.sessionListSucceeded(); + if (!this._didCompleteInitialSessionList) { + this._didCompleteInitialSessionList = true; + mark('code/agentHost/didListSessions'); + } + return sessions; + }, + error => { + this._startupTelemetry?.sessionListFailed(); + throw error; + }, + ); } createSession(config?: IAgentCreateSessionConfig): Promise { diff --git a/src/vs/platform/agentHost/test/electron-browser/localAgentHostService.test.ts b/src/vs/platform/agentHost/test/electron-browser/localAgentHostService.test.ts index 13d676fe8cfdd5..72df994010e23c 100644 --- a/src/vs/platform/agentHost/test/electron-browser/localAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/localAgentHostService.test.ts @@ -8,10 +8,25 @@ import { IChannelClient, IChannelServer, IServerChannel } from '../../../../base import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { NullLogService } from '../../../log/common/log.js'; +import { ITelemetryData } from '../../../telemetry/common/telemetry.js'; +import { NullTelemetryServiceShape } from '../../../telemetry/common/telemetryUtils.js'; import { AGENT_HOST_CLIENT_PROXY_CHANNEL } from '../../common/agentHostClientProxyChannel.js'; import { AGENT_HOST_CLIENT_BYOK_LM_CHANNEL, AgentHostClientByokLmChannel } from '../../common/agentHostClientByokLmChannel.js'; +import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; +import { AgentHostStartupTelemetry } from '../../common/agentHostStartupTelemetry.js'; +import { AgentHostClientConnectionKind } from '../../common/agentHostTelemetry.js'; import { LocalAgentHostManagementConnection, registerAgentHostClientChannels } from '../../electron-browser/localAgentHostService.js'; +class TestTelemetryService extends NullTelemetryServiceShape { + readonly events: { eventName: string; data: ITelemetryData | undefined }[] = []; + + override publicLog2(eventName?: string, data?: ITelemetryData): void { + if (eventName) { + this.events.push({ eventName, data }); + } + } +} + /** * Regression coverage for the renderer reverse-RPC channel registration. The * BYOK language-model bridge depends on `IAgentHostByokLmHandler`, registered by @@ -106,3 +121,179 @@ suite('registerAgentHostClientChannels', () => { assert.deepStrictEqual(registered, [AGENT_HOST_CLIENT_PROXY_CHANNEL]); }); }); + +suite('AgentHostStartupTelemetry', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('reports the first successful session list with startup milestones and retry counts', () => { + let now = 0; + const telemetryService = new TestTelemetryService(); + const tracker = store.add(new AgentHostStartupTelemetry( + AgentHostClientType.AgentsWindow, + AgentHostClientConnectionKind.Local, + () => ({ elapsed: () => now }), + () => ({ dispose() { } }), + telemetryService, + )); + + now = 10; + tracker.messagePortAcquired(); + now = 20; + tracker.sessionListRequested(); + now = 50; + tracker.protocolConnected(); + now = 80; + tracker.authenticationSettled(); + now = 85; + tracker.sessionListRequested(); + now = 90; + tracker.sessionListFailed(); + now = 120; + tracker.sessionListSucceeded(); + tracker.connectionFailed(); + + assert.deepStrictEqual(telemetryService.events, [{ + eventName: 'agentHost.startup', + data: { + clientType: 'agents_window', + connectionKind: 'local', + outcome: 'success', + failureStage: undefined, + timeToMessagePortMs: 10, + timeToProtocolConnectionMs: 50, + timeToAuthenticationSettledMs: 80, + timeToSessionListRequestMs: 20, + timeToSessionListCompleteMs: 120, + sessionListDurationMs: 100, + sessionListAttemptCount: 2, + sessionListFailureCount: 1, + }, + }]); + }); + + test('reports a protocol connection failure once', () => { + let now = 30; + const telemetryService = new TestTelemetryService(); + const tracker = store.add(new AgentHostStartupTelemetry( + AgentHostClientType.EditorWindow, + AgentHostClientConnectionKind.Local, + () => ({ elapsed: () => now }), + () => ({ dispose() { } }), + telemetryService, + )); + + tracker.connectionFailed(); + now = 40; + tracker.sessionListRequested(); + tracker.sessionListSucceeded(); + + assert.deepStrictEqual(telemetryService.events, [{ + eventName: 'agentHost.startup', + data: { + clientType: 'editor_window', + connectionKind: 'local', + outcome: 'error', + failureStage: 'protocolConnection', + timeToMessagePortMs: undefined, + timeToProtocolConnectionMs: undefined, + timeToAuthenticationSettledMs: undefined, + timeToSessionListRequestMs: undefined, + timeToSessionListCompleteMs: undefined, + sessionListDurationMs: undefined, + sessionListAttemptCount: 0, + sessionListFailureCount: 0, + }, + }]); + }); + + test('attributes a terminal connection failure after connecting to the session-list stage', () => { + let now = 50; + const telemetryService = new TestTelemetryService(); + const tracker = store.add(new AgentHostStartupTelemetry( + AgentHostClientType.AgentsWindow, + AgentHostClientConnectionKind.Local, + () => ({ elapsed: () => now }), + () => ({ dispose() { } }), + telemetryService, + )); + + tracker.protocolConnected(); + now = 70; + tracker.sessionListRequested(); + now = 90; + tracker.connectionFailed(); + + assert.deepStrictEqual(telemetryService.events, [{ + eventName: 'agentHost.startup', + data: { + clientType: 'agents_window', + connectionKind: 'local', + outcome: 'error', + failureStage: 'sessionList', + timeToMessagePortMs: undefined, + timeToProtocolConnectionMs: 50, + timeToAuthenticationSettledMs: undefined, + timeToSessionListRequestMs: 70, + timeToSessionListCompleteMs: undefined, + sessionListDurationMs: undefined, + sessionListAttemptCount: 1, + sessionListFailureCount: 0, + }, + }]); + }); + + test('reports a session-list timeout after the protocol connected', () => { + let onTimeout = () => { }; + const telemetryService = new TestTelemetryService(); + const tracker = store.add(new AgentHostStartupTelemetry( + AgentHostClientType.AgentsWindow, + AgentHostClientConnectionKind.Local, + () => ({ elapsed: () => 120_000 }), + callback => { + onTimeout = callback; + return { dispose() { } }; + }, + telemetryService, + )); + + tracker.protocolConnected(); + tracker.sessionListRequested(); + tracker.sessionListFailed(); + onTimeout(); + + assert.deepStrictEqual(telemetryService.events, [{ + eventName: 'agentHost.startup', + data: { + clientType: 'agents_window', + connectionKind: 'local', + outcome: 'timeout', + failureStage: 'sessionList', + timeToMessagePortMs: undefined, + timeToProtocolConnectionMs: 120_000, + timeToAuthenticationSettledMs: undefined, + timeToSessionListRequestMs: 120_000, + timeToSessionListCompleteMs: undefined, + sessionListDurationMs: undefined, + sessionListAttemptCount: 1, + sessionListFailureCount: 1, + }, + }]); + }); + + test('does not report a connection failure after disposal', () => { + const telemetryService = new TestTelemetryService(); + const tracker = new AgentHostStartupTelemetry( + AgentHostClientType.EditorWindow, + AgentHostClientConnectionKind.Local, + () => ({ elapsed: () => 10 }), + () => ({ dispose() { } }), + telemetryService, + ); + + tracker.dispose(); + tracker.connectionFailed(); + + assert.deepStrictEqual(telemetryService.events, []); + }); +}); From 68a50091374308ed053934645b8d82e95524f54f Mon Sep 17 00:00:00 2001 From: roblourens Date: Sat, 15 Aug 2026 13:32:38 -0700 Subject: [PATCH 6/8] sessions: Fix chat input model disposal race (#331041) Keep the synchronously created input model alive until its resolver reference takes ownership, and dispose late references when the widget has already closed. Add a regression test for immediate disposal during reference acquisition.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/newChatInput.ts | 24 ++--- .../chat/test/browser/newChatInput.test.ts | 98 +++++++++++++++++++ 2 files changed, 111 insertions(+), 11 deletions(-) create mode 100644 src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index 069c063f0910ea..06a5b96bc25dc3 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -12,7 +12,7 @@ import { CancellationToken, CancellationTokenSource } from '../../../../base/com import { Codicon } from '../../../../base/common/codicons.js'; import { Emitter } from '../../../../base/common/event.js'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; -import { Disposable, DisposableStore, IReference, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable, thenRegisterOrDispose, toDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { Schemas } from '../../../../base/common/network.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; @@ -21,8 +21,9 @@ import { IMenuEntryActionViewItemOptions, MenuEntryActionViewItem } from '../../ import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; import { EditorExtensionsRegistry } from '../../../../editor/browser/editorExtensions.js'; import { IEditorConstructionOptions } from '../../../../editor/browser/config/editorConfiguration.js'; +import { ITextModel } from '../../../../editor/common/model.js'; import { IModelService } from '../../../../editor/common/services/model.js'; -import { IResolvedTextEditorModel, ITextModelService } from '../../../../editor/common/services/resolverService.js'; +import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; import { EDITOR_FONT_DEFAULTS } from '../../../../editor/common/config/fontInfo.js'; import { EditorOptions } from '../../../../editor/common/config/editorOptions.js'; import { SuggestController } from '../../../../editor/contrib/suggest/browser/suggestController.js'; @@ -356,7 +357,6 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation // Input private _editor!: CodeEditorWidget; private _editorContainer!: HTMLElement; - private readonly _inputModelReference = this._register(new MutableDisposable>()); private _sessionControlsContainer: HTMLElement | undefined; private readonly _promptTemplatePlaceholder = this._register(new MutableDisposable()); private readonly _promptOptionsWidget = this._register(new MutableDisposable()); @@ -702,12 +702,14 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation * input model and force-destroys it when the last reference is released. * Holding one keeps the model alive for this editor's lifetime. */ - private async _holdInputModelReference(uri: URI): Promise { - try { - this._inputModelReference.value = await this.textModelService.createModelReference(uri); - } catch (error) { - this.logService.error('Failed to hold the chat input model reference', error); - } + private _holdInputModelReference(uri: URI, model: ITextModel): void { + const inputModelReference = thenRegisterOrDispose(this.textModelService.createModelReference(uri), this._store); + void inputModelReference.catch(error => { + model.dispose(); + if (!this._store.isDisposed) { + this.logService.error('Failed to hold the chat input model reference', error); + } + }); } private _createEditor(container: HTMLElement, overflowWidgetsDomNode: HTMLElement): void { @@ -725,8 +727,8 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation const scopedInstantiationService = this._register(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, inputScopedContextKeyService]))); const uri = URI.from({ scheme: Schemas.sessionsChatInput, path: `input-${Date.now()}` }); - const textModel = this._register(this.modelService.createModel('', null, uri, true)); - void this._holdInputModelReference(uri); + const textModel = this.modelService.createModel('', null, uri, true); + this._holdInputModelReference(uri, textModel); const editorOptions: IEditorConstructionOptions = { ...getSimpleEditorOptions(this.configurationService), diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts b/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts new file mode 100644 index 00000000000000..ebbfff217729cf --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DeferredPromise } from '../../../../../base/common/async.js'; +import { DisposableStore, IDisposable, IReference } from '../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ITextModel } from '../../../../../editor/common/model.js'; +import { IResolvedTextEditorModel } from '../../../../../editor/common/services/resolverService.js'; +import { NewChatInputWidget } from '../../browser/newChatInput.js'; + +interface IInputModelReferenceHarness { + readonly _store: DisposableStore; + readonly textModelService: { + createModelReference(resource: URI): Promise>; + }; + readonly logService: { + error(message: string, error: Error): void; + }; + _register(disposable: T): T; +} + +const holdInputModelReference = Reflect.get(NewChatInputWidget.prototype, '_holdInputModelReference') as (this: IInputModelReferenceHarness, uri: URI, model: ITextModel) => void; + +class InputModelReferenceHarness implements IInputModelReferenceHarness, IDisposable { + readonly _store = new DisposableStore(); + + constructor( + readonly textModelService: IInputModelReferenceHarness['textModelService'], + readonly logService: IInputModelReferenceHarness['logService'], + ) { } + + _register(disposable: T): T { + return this._store.add(disposable); + } + + dispose(): void { + this._store.dispose(); + } +} + +suite('NewChatInputWidget', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('keeps the input model alive until reference acquisition settles during disposal', async () => { + const referenceDeferred = new DeferredPromise>(); + let modelDisposed = false; + let referenceDisposed = false; + const errors: { message: string; error: Error }[] = []; + const model = new class extends mock() { + override dispose(): void { + modelDisposed = true; + } + }(); + const resolvedModel = new class extends mock() { + override readonly textEditorModel = model; + }(); + const harness = disposables.add(new InputModelReferenceHarness( + { + createModelReference: () => referenceDeferred.p, + }, + { + error: (message, error) => errors.push({ message, error }), + }, + )); + + holdInputModelReference.call(harness, URI.from({ scheme: Schemas.sessionsChatInput, path: 'input-test' }), model); + harness.dispose(); + const disposedBeforeReferenceSettled = modelDisposed; + + referenceDeferred.complete({ + object: resolvedModel, + dispose: () => { + referenceDisposed = true; + model.dispose(); + }, + }); + await referenceDeferred.p; + await Promise.resolve(); + + assert.deepStrictEqual({ + disposedBeforeReferenceSettled, + modelDisposed, + referenceDisposed, + errors, + }, { + disposedBeforeReferenceSettled: false, + modelDisposed: true, + referenceDisposed: true, + errors: [], + }); + }); +}); From 962cde6575e27bdcf58c8aa2d411ae5170c31e88 Mon Sep 17 00:00:00 2001 From: roblourens Date: Sat, 15 Aug 2026 13:59:04 -0700 Subject: [PATCH 7/8] agentHost: Preserve detached shells during idle eviction (#330132) * agentHost: Preserve detached shells during idle eviction Let SDK session.idle complete the AHP turn normally, but defer non-destructive provider release while Copilot reports a running detached shell. Keep cached state until release succeeds, retry deferred releases, and preserve reconnect ordering while release is in flight. Add focused unit coverage plus a real bundled-SDK mock-LLM integration test that verifies detached work survives beyond the release grace. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: Normalize idle release coordination Key idle-release timers by the owning root session so chat and subagent subscriptions cancel and re-arm the same retry. Preserve the original in-flight provider release promise when overlapping timers fire. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: Adapt idle release to chat lifecycle Split idle release into a provider preflight and chat teardown so detached shells can veto eviction while successful releases preserve state-first reconstruction. Normalize release coordination across all chats and update restart coverage to unsubscribe every open chat. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: Remove no-op Claude release change Restore the original combined release guard after the chat release contract returned to void semantics. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/platform/agentHost/common/agent.ts | 3 + .../platform/agentHost/node/agentService.ts | 162 ++++++++++------ .../agentHost/node/copilot/copilotAgent.ts | 16 ++ .../node/copilot/copilotAgentSession.ts | 13 ++ .../agentHost/test/node/agentService.test.ts | 177 +++++++++++++++++- .../agentHost/test/node/copilotAgent.test.ts | 6 + .../test/node/copilotAgentSession.test.ts | 82 ++++++++ .../e2e/suites/sessionPersistenceSuite.ts | 10 +- .../copilotMockLlm.integrationTest.ts | 112 ++++++++++- 9 files changed, 507 insertions(+), 74 deletions(-) diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index a50d799db37d13..cc5ee6b72368c7 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -691,6 +691,9 @@ export interface IAgentChats { /** Dispose the addressed chat and free its backing. */ disposeChat(chat: URI, context: AgentChatOperationContext): Promise; + /** Return whether the addressed chat can currently release its in-memory backing. */ + canReleaseChat?(chat: URI, context: AgentChatOperationContext): Promise; + /** Release the addressed chat's in-memory backing without deleting durable data. */ releaseChat(chat: URI, context: AgentChatOperationContext): Promise; diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 6a29c67c621fd0..358049a9e71e1d 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -2371,6 +2371,15 @@ export class AgentService extends Disposable implements IAgentService { * Idle eviction must use {@link IAgentChats.releaseChat}, not destructive * session finalization, so the session remains resumable. */ + private async _canReleaseSession(provider: IAgent, session: URI, chats: readonly URI[]): Promise { + for (const chat of chats) { + if (provider.chats.canReleaseChat && !await provider.chats.canReleaseChat(chat, this._chatContext(session, chat))) { + return false; + } + } + return true; + } + private async _releaseSession(provider: IAgent, session: URI, chats: readonly URI[]): Promise { await this._defaultChatBackingWrites.get(session.toString())?.catch(() => { }); // Still release every catalog chat if one rejects; otherwise an idle-evicted @@ -3030,16 +3039,13 @@ export class AgentService extends Disposable implements IAgentService { async subscribe(resource: URI, clientId: string): Promise { this._logService.trace(`[AgentService] subscribe: ${resource.toString()}`); const resourceStr = resource.toString(); - // Register the subscriber up front so a concurrent unsubscribe cannot - // evict the session state while we are awaiting restore. On any failure - // path below we must roll the registration back, otherwise the leaked - // refcount would permanently pin (or block eviction of) the resource. - // {@link addSubscriber} is the single point that triggers the - // uncommitted-changeset refresh on the 0→1 transition (covers both - // the cold-snapshot path here and the handshake fast-path used by - // {@link ProtocolServerHandler} when state is already cached). - this.addSubscriber(resource, clientId); try { + await this._releaseSessionInFlight.get(this._sessionReleaseKey(resource)); + // Register after an in-flight release settles so a successful release + // can evict cached state and this subscribe reconstructs it. The + // handshake fast path calls addSubscriber directly and therefore pins + // its already-returned snapshot instead. + this.addSubscriber(resource, clientId); // Check for terminal state const terminalState = this._terminalManager.getTerminalState(resourceStr); if (terminalState) { @@ -3132,6 +3138,18 @@ export class AgentService extends Disposable implements IAgentService { } } + private _sessionReleaseKey(resource: URI): string { + const resourceString = resource.toString(); + const changesetSession = parseChangesetUri(resourceString)?.sessionUri; + const chatSession = parseDefaultChatUri(resourceString); + let session = URI.parse(changesetSession ?? chatSession ?? resourceString); + let subagent; + while ((subagent = parseSubagentSessionUri(session))) { + session = subagent.parentSession; + } + return session.toString(); + } + /** Waits for an armed subagent chat to register (or its wait to time out); returns `undefined` if not armed or never registered. */ private async _awaitPendingSubagentChat(subagentChatUri: string): Promise { const pending = this._pendingSubagentChats.get(subagentChatUri); @@ -3191,16 +3209,25 @@ export class AgentService extends Disposable implements IAgentService { // and keeps the live provider SDK session, avoiding a disconnect/resume // churn cycle that races concurrent session operations on the shared // provider runtime. A zero grace releases on the next tick. - this._pendingSessionRelease.set(resource, disposableTimeout(() => { - this._pendingSessionRelease.deleteAndDispose(resource); - void this._maybeEvictIdleSession(resource).catch(err => { - this._logService.error(err, `[AgentService] Failed to evict idle session ${resource.toString()}`); + this._scheduleSessionRelease(resource); + } + + private _cancelPendingSessionRelease(resource: URI): void { + this._pendingSessionRelease.deleteAndDispose(this._sessionReleaseResource(resource)); + } + + private _scheduleSessionRelease(resource: URI): void { + const session = this._sessionReleaseResource(resource); + this._pendingSessionRelease.set(session, disposableTimeout(() => { + this._pendingSessionRelease.deleteAndDispose(session); + void this._maybeEvictIdleSession(session).catch(err => { + this._logService.error(err, `[AgentService] Failed to evict idle session ${session.toString()}`); }); }, SESSION_RELEASE_GRACE_MS)); } - private _cancelPendingSessionRelease(resource: URI): void { - this._pendingSessionRelease.deleteAndDispose(resource); + private _sessionReleaseResource(resource: URI): URI { + return URI.parse(this._sessionReleaseKey(resource)); } /** @@ -3291,28 +3318,11 @@ export class AgentService extends Disposable implements IAgentService { */ private async _maybeEvictIdleSession(resource: URI): Promise { const key = resource.toString(); - if (this._resourceSubscribers.has(resource)) { - return; - } - // Walk up the subagent ancestry: the SDK session and its turn tree are - // owned by the root session, so eviction must target the root. - let evictionTarget = resource; - { - let parsed; - while ((parsed = parseSubagentSessionUri(evictionTarget))) { - evictionTarget = parsed.parentSession; - } - } - // Don't evict if the root or any of its subagent descendants still has subscribers. - if (this._resourceSubscribers.has(evictionTarget)) { + const evictionTarget = this._sessionReleaseResource(resource); + const evictionTargetKey = evictionTarget.toString(); + if (this._hasSessionSubscribers(evictionTarget)) { return; } - for (const subscribedUri of this._resourceSubscribers.keys()) { - if (this._isSubagentDescendantOf(subscribedUri, evictionTarget)) { - return; - } - } - const evictionTargetKey = evictionTarget.toString(); // A restore/resume racing this unsubscribe means a client is about to // observe the session again; releasing now would tear down state that // the in-flight rehydrate is populating. @@ -3320,46 +3330,56 @@ export class AgentService extends Disposable implements IAgentService { return; } const targetState = this._stateManager.getSessionState(evictionTargetKey); - if (!targetState || targetState.activeTurn !== undefined) { + if (!targetState) { + return; + } + if (targetState.activeTurn !== undefined) { + this._scheduleSessionRelease(evictionTarget); + return; + } + if (this._releaseSessionInFlight.has(evictionTargetKey)) { return; } const chats = this._getSessionChatsInTeardownOrder(evictionTarget); await this._whenSessionDataIdle(evictionTarget); - if (this._resourceSubscribers.has(evictionTarget) || this._restoreSessionInFlight.has(evictionTargetKey)) { + if (this._hasSessionSubscribers(evictionTarget) || this._restoreSessionInFlight.has(evictionTargetKey) || this._releaseSessionInFlight.has(evictionTargetKey)) { return; } - for (const subscribedUri of this._resourceSubscribers.keys()) { - if (this._isSubagentDescendantOf(subscribedUri, evictionTarget)) { - return; - } - } const settledState = this._stateManager.getSessionState(evictionTargetKey); if (!settledState || settledState.activeTurn !== undefined) { return; } - this._logService.info(`[AgentService] Evicting idle session: ${evictionTargetKey} (triggered by unsubscribe of ${key})`); - // Also evict any sibling subagent entries cached under the parent: their - // authoritative state is the parent's turn tree, and dropping the parent - // would leave them orphaned. - const subagentPrefix = buildSubagentSessionUriPrefix(evictionTarget); - for (const cachedKey of this._stateManager.getSessionUrisWithPrefix(subagentPrefix)) { - this._stateManager.removeSession(cachedKey); - } - this._sideEffects.clearSessionTitleState(evictionTargetKey, settledState.chats.map(chat => chat.resource)); - this._stateManager.removeSession(evictionTargetKey); - // Release the provider's in-memory SDK session in lockstep with the - // cached state. Non-destructive: durable data is preserved so the - // session resumes transparently on the next access. Fire-and-forget — - // the provider sequences the release internally and re-checks its own - // invariants (e.g. a turn that started after this call). const provider = this._findProviderForSession(evictionTarget); if (!provider) { return; } - const release = this._releaseSession(provider, evictionTarget, chats); - const trackedRelease = release.catch(err => { - this._logService.error(err, `[AgentService] Failed to release idle session ${evictionTargetKey}`); - }); + const trackedRelease = (async () => { + try { + if (!await this._canReleaseSession(provider, evictionTarget, chats)) { + if (!this._hasSessionSubscribers(evictionTarget)) { + this._scheduleSessionRelease(evictionTarget); + } + return; + } + const currentState = this._stateManager.getSessionState(evictionTargetKey); + if (this._hasSessionSubscribers(evictionTarget)) { + return; + } + if (this._restoreSessionInFlight.has(evictionTargetKey) || currentState?.activeTurn !== undefined) { + this._scheduleSessionRelease(evictionTarget); + return; + } + if (currentState) { + this._evictSessionState(evictionTarget, evictionTargetKey, key, currentState.chats.map(chat => chat.resource)); + } + await this._releaseSession(provider, evictionTarget, chats); + } catch (err) { + this._logService.error(err, `[AgentService] Failed to release idle session ${evictionTargetKey}`); + if (!this._hasSessionSubscribers(evictionTarget)) { + this._scheduleSessionRelease(evictionTarget); + } + } + })(); this._releaseSessionInFlight.set(evictionTargetKey, trackedRelease); void trackedRelease.then(() => { if (this._releaseSessionInFlight.get(evictionTargetKey) === trackedRelease) { @@ -3368,6 +3388,26 @@ export class AgentService extends Disposable implements IAgentService { }); } + private _hasSessionSubscribers(session: URI): boolean { + const sessionKey = this._sessionReleaseKey(session); + for (const subscribedUri of this._resourceSubscribers.keys()) { + if (this._sessionReleaseKey(subscribedUri) === sessionKey) { + return true; + } + } + return false; + } + + private _evictSessionState(evictionTarget: URI, evictionTargetKey: string, triggerKey: string, chats: readonly string[]): void { + this._logService.info(`[AgentService] Evicting idle session: ${evictionTargetKey} (triggered by unsubscribe of ${triggerKey})`); + const subagentPrefix = buildSubagentSessionUriPrefix(evictionTarget); + for (const cachedKey of this._stateManager.getSessionUrisWithPrefix(subagentPrefix)) { + this._stateManager.removeSession(cachedKey); + } + this._sideEffects.clearSessionTitleState(evictionTargetKey, chats); + this._stateManager.removeSession(evictionTargetKey); + } + // Returns true when a changeset is safe to drop from the in-memory cache. private _isChangesetEvictable(changeset: string): boolean { const changesetUri = URI.parse(changeset); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index c7115650cb11af..3dfc29ee36905a 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -2459,6 +2459,7 @@ export class CopilotAgent extends Disposable implements IAgent { return this._createChat(chat, resolveAgentChatContext(context, chat), options); }, disposeChat: (chatUri: URI, context: URI | IAgentChatContext): Promise => this._disposeChat(chatUri, context), + canReleaseChat: (chatUri: URI, context: URI | IAgentChatContext): Promise => this._canReleaseChat(chatUri, context), releaseChat: (chatUri: URI, context: URI | IAgentChatContext): Promise => this._releaseChat(chatUri, context), sendMessage: (chatUri: URI, prompt: string, workingDirectoriesOrDirectory: readonly URI[] | URI | undefined, attachments?: readonly MessageAttachment[], turnId?: string, senderClientId?: string, clientTypeOrContext?: AgentHostClientType | URI | IAgentChatContext, context?: URI | IAgentChatContext): Promise => { const workingDirectories = Array.isArray(workingDirectoriesOrDirectory) ? workingDirectoriesOrDirectory : workingDirectoriesOrDirectory ? [workingDirectoriesOrDirectory] : undefined; @@ -3678,6 +3679,21 @@ export class CopilotAgent extends Disposable implements IAgent { } } + private async _canReleaseChat(chat: URI, operationContext: URI | IAgentChatContext): Promise { + const target = this._resolveChatContext(chat, operationContext).target; + if (!target) { + return true; + } + if (target.hasActiveTurn) { + return false; + } + if (await target.hasRunningDetachedShells()) { + this._logService.info(`[Copilot:${target.sessionId}] Deferring idle release while a detached shell is running`); + return false; + } + return true; + } + private async _releaseChat(chat: URI, operationContext: URI | IAgentChatContext): Promise { const initial = this._resolveChatContext(chat, operationContext); const lifetime = this._getOrCreateSessionLifetime(initial.sdkSessionId ?? initial.configurationId); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index c0f14f1d5fdbce..e897e99f70c699 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -1252,6 +1252,19 @@ export class CopilotAgentSession extends Disposable { this._currentTurn = new CopilotTurn(turnId, this._nextTurnOrdinal++, senderClientId, clientContext); } + async hasRunningDetachedShells(): Promise { + try { + await this._wrapper.session.rpc.tasks.refresh(); + const tasks = await this._wrapper.session.rpc.tasks.list(); + return tasks.tasks.some(task => task.type === 'shell' + && task.attachmentMode === 'detached' + && (task.status === 'running' || task.status === 'idle')); + } catch (err) { + this._logService.warn(`[Copilot:${this.sessionId}] Failed to read detached shell state; deferring release: ${getErrorMessage(err)}`); + return true; + } + } + /** Refreshes prompt-cache state and the session-wide nano-AIU total from the SDK's authoritative usage metrics. */ private async _refreshSessionUsageMetrics(): Promise { try { diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index a65799a1357b0c..9efd41365ffdbc 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -9325,6 +9325,36 @@ suite('AgentService (node dispatcher)', () => { } } + class DeferringReleaseMockAgent extends MockAgent { + releaseAttempts = 0; + + override readonly chats: IAgentChats = withChatOverrides(getChatSurface(this), base => ({ + canReleaseChat: async () => { + this.releaseAttempts++; + return this.releaseAttempts !== 1; + }, + releaseChat: (chat, context) => base.releaseChat(chat, context), + })); + } + + class DelayedCanReleaseMockAgent extends MockAgent { + readonly canRelease = new DeferredPromise(); + readonly events: string[] = []; + + override readonly chats: IAgentChats = withChatOverrides(getChatSurface(this), base => ({ + canReleaseChat: async () => { + this.events.push('canRelease:start'); + await this.canRelease.p; + this.events.push('canRelease:end'); + return true; + }, + releaseChat: async (chat, context) => { + this.events.push('release'); + await base.releaseChat(chat, context); + }, + })); + } + test('an empty session created in this lifetime stays observable until GC fires', async () => { service.registerProvider(copilotAgent); const sessionResource = await service.createSession({ provider: 'copilot' }); @@ -9357,6 +9387,104 @@ suite('AgentService (node dispatcher)', () => { assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'active-turn session must not be evicted'); }); + test('a provider can defer idle release without losing cached state', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + const agent = new DeferringReleaseMockAgent('copilot'); + service.registerProvider(agent); + const { session } = await createAgentSession(agent); + agent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] }, + ]; + await service.restoreSession(session); + service.addSubscriber(session, 'client-1'); + service.unsubscribe(session, 'client-1'); + + await new Promise(resolve => setTimeout(resolve, 30_000)); + assert.deepStrictEqual({ + releaseAttempts: agent.releaseAttempts, + hasCachedState: service.stateManager.getSessionState(session.toString()) !== undefined, + }, { + releaseAttempts: 1, + hasCachedState: true, + }); + + await new Promise(resolve => setTimeout(resolve, 30_000)); + assert.deepStrictEqual({ + releaseAttempts: agent.releaseAttempts, + hasCachedState: service.stateManager.getSessionState(session.toString()) !== undefined, + }, { + releaseAttempts: 2, + hasCachedState: false, + }); + }); + }); + + test('chat subscription cancels the root release retry and gets a fresh grace period', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + const agent = new DeferringReleaseMockAgent('copilot'); + service.registerProvider(agent); + const { session } = await createAgentSession(agent); + const chatResource = URI.parse(buildDefaultChatUri(session)); + agent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] }, + ]; + await service.restoreSession(session); + service.addSubscriber(session, 'client-session'); + service.unsubscribe(session, 'client-session'); + + await new Promise(resolve => setTimeout(resolve, 30_000)); + assert.strictEqual(agent.releaseAttempts, 1); + + service.addSubscriber(chatResource, 'client-chat'); + await new Promise(resolve => setTimeout(resolve, 10_000)); + service.unsubscribe(chatResource, 'client-chat'); + await new Promise(resolve => setTimeout(resolve, 20_000)); + assert.strictEqual(agent.releaseAttempts, 1, 'the cancelled root retry must not fire at its original deadline'); + await new Promise(resolve => setTimeout(resolve, 9_999)); + assert.strictEqual(agent.releaseAttempts, 1, 'chat disconnect should receive a fresh release grace'); + await new Promise(resolve => setTimeout(resolve, 1)); + + assert.deepStrictEqual({ + releaseAttempts: agent.releaseAttempts, + hasCachedState: service.stateManager.getSessionState(session.toString()) !== undefined, + }, { + releaseAttempts: 2, + hasCachedState: false, + }); + }); + }); + + test('overlapping root release timers preserve the original in-flight release', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + const agent = new DelayedReleaseMockAgent('copilot'); + service.registerProvider(agent); + const { session } = await createAgentSession(agent); + const chatResource = URI.parse(buildDefaultChatUri(session)); + agent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] }, + ]; + await service.restoreSession(session); + agent.events.length = 0; + service.addSubscriber(session, 'client-session'); + service.unsubscribe(session, 'client-session'); + + await new Promise(resolve => setTimeout(resolve, 30_000)); + assert.deepStrictEqual(agent.events, ['release:start']); + + service.addSubscriber(chatResource, 'client-chat'); + service.unsubscribe(chatResource, 'client-chat'); + await new Promise(resolve => setTimeout(resolve, 30_000)); + assert.deepStrictEqual(agent.events, ['release:start'], 'second timer must not start another provider release'); + + await agent.release.complete(); + await Promise.resolve(); + assert.deepStrictEqual(agent.events, ['release:start', 'release:end']); + }); + }); + test('a restored idle session is evicted when its last subscriber drops', () => { return runWithFakedTimers({ useFakeTimers: true }, async () => { service.registerProvider(copilotAgent); @@ -9478,7 +9606,7 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('restore waits for provider release to finish', () => { + test('subscription waits for provider release and restores evicted state', () => { return runWithFakedTimers({ useFakeTimers: true }, async () => { const agent = new DelayedReleaseMockAgent('copilot'); service.registerProvider(agent); @@ -9496,10 +9624,51 @@ suite('AgentService (node dispatcher)', () => { service.unsubscribe(sessionResource, 'client-1'); await new Promise(resolve => setTimeout(resolve, 30_000)); - const restore = service.subscribe(sessionResource, 'client-2'); + let subscriptionSettled = false; + const subscription = service.subscribe(sessionResource, 'client-2').then(result => { + subscriptionSettled = true; + return result; + }); + await Promise.resolve(); + assert.strictEqual(subscriptionSettled, false); await agent.release.complete(); - await restore; - assert.deepStrictEqual(agent.events, ['release:start', 'release:end', 'metadata']); + await subscription; + assert.deepStrictEqual({ + events: agent.events, + hasCachedState: service.stateManager.getSessionState(sessionResource.toString()) !== undefined, + }, { + events: ['release:start', 'release:end', 'metadata'], + hasCachedState: true, + }); + }); + }); + + test('initial subscriber added during release preflight keeps cached state', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + const agent = new DelayedCanReleaseMockAgent('copilot'); + service.registerProvider(agent); + const { session } = await createAgentSession(agent); + agent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] }, + ]; + await service.restoreSession(session); + agent.events.length = 0; + service.addSubscriber(session, 'client-1'); + service.unsubscribe(session, 'client-1'); + await new Promise(resolve => setTimeout(resolve, 30_000)); + + service.addSubscriber(session, 'client-2'); + await agent.canRelease.complete(); + await Promise.resolve(); + + assert.deepStrictEqual({ + events: agent.events, + hasCachedState: service.stateManager.getSessionState(session.toString()) !== undefined, + }, { + events: ['canRelease:start', 'canRelease:end'], + hasCachedState: true, + }); }); }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 33bff0fade41a5..98b028bdccff20 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -787,6 +787,7 @@ class TestableCopilotAgent extends CopilotAgent { appliedSnapshot: undefined, dispose: fake.dispose, onDidRequireAuth: Event.None, + hasRunningDetachedShells: async () => false, resetTurnState: (newTurnId: string) => { turnId = newTurnId; }, emitInitialMarkdown: (content: string) => { emitter.fire({ @@ -7215,6 +7216,7 @@ suite('CopilotAgent', () => { resetTurnState(turnId: string, senderClientId: string | undefined): void { rec.resets.push({ turnId, senderClientId }); }, async setModel(id: string, reasoningEffort?: string, contextTier?: string): Promise { rec.modelCalls.push({ id, effort: reasoningEffort, tier: contextTier }); }, async setAgent(name: string | undefined): Promise { rec.agentCalls.push(name); }, + async hasRunningDetachedShells(): Promise { return false; }, handleClientToolCallComplete(): void { }, async getNextTurnEventId(): Promise { return undefined; }, getMessages: getMessages ?? (async () => []), @@ -7495,6 +7497,8 @@ suite('CopilotAgent', () => { setPeerChatStub(agent, chat, { workingDirectory: URI.file('/workspace'), hasActiveTurn: false, + async hasRunningDetachedShells() { return false; }, + async getMessages() { return []; }, async destroySession() { releaseStarted = true; await releaseGate.p; @@ -7602,6 +7606,7 @@ suite('CopilotAgent', () => { setPeerChatStub(agent, waitingChat, { workingDirectory: URI.file('/workspace'), hasActiveTurn: false, + async hasRunningDetachedShells() { return false; }, async destroySession() { releaseStarted = true; await releaseGate.p; @@ -8400,6 +8405,7 @@ suite('CopilotAgent', () => { async setAgent(name: string | undefined): Promise { rec.agentCalls.push(name); }, async abort(): Promise { rec.aborted++; }, async getMessages(): Promise { return [{ id: `turn-${key}` } as unknown as Turn]; }, + async hasRunningDetachedShells(): Promise { return false; }, handleClientToolCallComplete(): void { }, dispose(): void { rec.disposed = true; }, } as unknown as CopilotAgentSession; diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 60002591224ea6..f86e33c0393f97 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -138,6 +138,10 @@ class MockCopilotSession { * Lets a test make an earlier-issued read resolve after a later one. */ readonly usageMetricsGates: Array> = []; + backgroundTasks: Awaited>['tasks'] = []; + backgroundTaskListCalls = 0; + backgroundTaskRefreshCalls = 0; + backgroundTaskListError: Error | undefined; private readonly _handlers = new Map void>>(); private readonly _allHandlers = new Set(); @@ -279,6 +283,22 @@ class MockCopilotSession { return this.commandInvokeResult; }, }, + tasks: { + list: async () => { + this.backgroundTaskListCalls++; + if (this.backgroundTaskListError) { + const error = this.backgroundTaskListError; + this.backgroundTaskListError = undefined; + throw error; + } + const tasks = this.backgroundTasks.map(task => ({ ...task })); + return { tasks }; + }, + refresh: async () => { + this.backgroundTaskRefreshCalls++; + return {}; + }, + }, mcp: { list: async () => { if (this.mcpListError !== undefined) { @@ -5795,6 +5815,68 @@ suite('CopilotAgentSession', () => { assert.ok(isAction(signals[0], ActionType.ChatTurnComplete)); }); + test('idle event completes the active turn while a detached shell runs', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + mockSession.backgroundTasks = [{ + type: 'shell', + id: 'shell-1', + description: 'Monitor CI', + status: 'running', + startedAt: new Date(0).toISOString(), + command: 'monitor-ci', + attachmentMode: 'detached', + executionMode: 'background', + }]; + session.resetTurnState('turn-background'); + mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); + + assert.deepStrictEqual({ + hasActiveTurn: session.hasActiveTurn, + completedTurns: getActions(signals).filter(action => action.type === ActionType.ChatTurnComplete).length, + listCalls: mockSession.backgroundTaskListCalls, + refreshCalls: mockSession.backgroundTaskRefreshCalls, + }, { + hasActiveTurn: false, + completedTurns: 1, + listCalls: 0, + refreshCalls: 0, + }); + }); + + test('running detached shell state defers release conservatively', async () => { + const { session, mockSession } = await createAgentSession(disposables); + const runningShell = { + type: 'shell' as const, + id: 'shell-running', + description: 'Monitor CI', + status: 'running' as const, + startedAt: new Date(0).toISOString(), + command: 'monitor-ci', + attachmentMode: 'detached' as const, + executionMode: 'background' as const, + }; + mockSession.backgroundTasks = [runningShell]; + const running = await session.hasRunningDetachedShells(); + mockSession.backgroundTasks = [{ ...runningShell, status: 'completed', completedAt: new Date().toISOString() }]; + const completed = await session.hasRunningDetachedShells(); + mockSession.backgroundTaskListError = new Error('transient tasks.list failure'); + const failedRead = await session.hasRunningDetachedShells(); + + assert.deepStrictEqual({ + running, + completed, + failedRead, + listCalls: mockSession.backgroundTaskListCalls, + refreshCalls: mockSession.backgroundTaskRefreshCalls, + }, { + running: true, + completed: false, + failedRead: true, + listCalls: 3, + refreshCalls: 3, + }); + }); + test('tool-call aggregate emits once with cancelled result across abort and idle', async () => { const telemetryService = new CapturingTelemetryService(); const { session, mockSession } = await createAgentSession(disposables, { diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts index c50bb4a36e16f5..2425ce25dd8d02 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import * as fs from 'fs'; import { tmpdir } from 'os'; -import { retry } from '../../../../../../base/common/async.js'; +import { retry, timeout } from '../../../../../../base/common/async.js'; import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; import type { SubscribeResult } from '../../../../common/state/protocol/commands.js'; @@ -65,14 +65,18 @@ export function defineSessionPersistenceTests(context: IAgentHostE2ETestContext) })); } - async function releaseAndRestoreSession(sessionUri: string): Promise { + async function releaseAndRestoreSession(sessionUri: string, additionalChats: readonly string[] = []): Promise { const before = await fetchSessionWithChat(context.client, sessionUri); const beforeResponsePartIds = responsePartIds(before.turns); const beforeTurns = durableTurnContent(before.turns); assert.ok(beforeResponsePartIds.length > 0); const chatUri = buildDefaultChatUri(sessionUri); + for (const chat of additionalChats) { + context.client.notify('unsubscribe', { channel: chat }); + } context.client.notify('unsubscribe', { channel: chatUri }); context.client.notify('unsubscribe', { channel: sessionUri }); + await timeout(50); await retry(async () => { const restored = await fetchSessionWithChat(context.client, sessionUri); @@ -156,7 +160,7 @@ export function defineSessionPersistenceTests(context: IAgentHostE2ETestContext) 60_000, ); - await releaseAndRestoreSession(sessionUri); + await releaseAndRestoreSession(sessionUri, [peerUri]); await restartAndInitialize(`peer-persistence-reconnect-${config.provider}`, workspace); const reopenedSession = await context.client.call('subscribe', { channel: sessionUri }); diff --git a/src/vs/platform/agentHost/test/node/providerIntegration/copilotMockLlm.integrationTest.ts b/src/vs/platform/agentHost/test/node/providerIntegration/copilotMockLlm.integrationTest.ts index e68143b9e45af0..3fbdd3abab6b61 100644 --- a/src/vs/platform/agentHost/test/node/providerIntegration/copilotMockLlm.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/providerIntegration/copilotMockLlm.integrationTest.ts @@ -8,14 +8,19 @@ */ import assert from 'assert'; -import { mkdtemp, rm } from 'fs/promises'; +import { existsSync } from 'fs'; +import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'; import { tmpdir } from 'os'; import { timeout } from '../../../../../base/common/async.js'; +import { join } from '../../../../../base/common/path.js'; +import { isWindows } from '../../../../../base/common/platform.js'; import { URI } from '../../../../../base/common/uri.js'; -import { buildDefaultChatUri, ResponsePartKind, type ISessionWithDefaultChat } from '../../../common/state/sessionState.js'; +import { ActionType, type ChatToolCallCompleteAction, type ChatToolCallReadyAction } from '../../../common/state/sessionActions.js'; +import { buildDefaultChatUri, ResponsePartKind, SessionStatus, type ISessionWithDefaultChat } from '../../../common/state/sessionState.js'; +import { ToolCallConfirmationReason } from '../../../common/state/protocol/channels-chat/state.js'; import { AgentHostSessionReleaseGraceMsEnvVar } from '../../../common/agentService.js'; import { createProviderSession, dispatchTurn, type IAgentHostProviderTestConfig } from '../providerIntegrationTestHelpers.js'; -import { fetchSessionWithChat, isActionNotification, IServerHandle, startRealServer, TestProtocolClient } from '../serverIntegrationTestHelpers.js'; +import { fetchSessionWithChat, getActionEnvelope, isActionNotification, IServerHandle, startRealServer, stopServer, TestProtocolClient } from '../serverIntegrationTestHelpers.js'; const COPILOT_CONFIG: IAgentHostProviderTestConfig = { provider: 'copilotcli', @@ -23,6 +28,13 @@ const COPILOT_CONFIG: IAgentHostProviderTestConfig = { githubToken: 'not-a-real-token', }; +const DETACHED_SHELL_SCENARIO_ID = 'detached-shell-idle-release'; +const DETACHED_SHELL_DELAY_MS = 6000; + +function quoteShellArgument(value: string): string { + return isWindows ? `'${value.replace(/'/g, '\'\'')}'` : `'${value.replace(/'/g, `'\\''`)}'`; +} + suite('Agent Host Provider Integration — Copilot with Mock LLM', function () { let server: IServerHandle; @@ -104,16 +116,51 @@ suite('Agent Host Provider Integration — Copilot Idle Release', function () { let server: IServerHandle; let client: TestProtocolClient; + let suiteHome: string; + let detachedCompletionMarker: string; const createdSessions: string[] = []; const tempDirs: string[] = []; suiteSetup(async function () { this.timeout(120_000); - server = await startRealServer({ mockLlm: true, env: { [AgentHostSessionReleaseGraceMsEnvVar]: String(RELEASE_GRACE_MS) } }); + suiteHome = await mkdtemp(`${tmpdir()}/test-mock-idle-release-home`); + detachedCompletionMarker = join(suiteHome, 'detached-shell-complete'); + const detachedScript = join(suiteHome, 'detached-shell.js'); + await writeFile(detachedScript, `setTimeout(() => require('fs').writeFileSync(${JSON.stringify(detachedCompletionMarker)}, 'done'), ${DETACHED_SHELL_DELAY_MS});`); + const command = `node ${quoteShellArgument(detachedScript)}`; + server = await startRealServer({ + mockLlm: true, + homeDir: suiteHome, + userDataDir: join(suiteHome, 'user-data'), + env: { [AgentHostSessionReleaseGraceMsEnvVar]: String(RELEASE_GRACE_MS) }, + mockScenarios: [{ + id: DETACHED_SHELL_SCENARIO_ID, + definition: { + type: 'multi-turn', + turns: [ + { + kind: 'tool-calls', + toolCalls: [{ + toolNamePattern: /^(bash|powershell)$/, + arguments: { + command, + description: 'Run detached shell release probe', + mode: 'async', + detach: true, + initial_wait: 30, + }, + }], + }, + { kind: 'content', chunks: [{ content: 'Waiting for detached shell completion.', delayMs: 0 }] }, + ], + }, + }], + }); }); - suiteTeardown(function () { - server?.process.kill(); + suiteTeardown(async function () { + await stopServer(server); + await rm(suiteHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); }); setup(async function () { @@ -139,6 +186,59 @@ suite('Agent Host Provider Integration — Copilot Idle Release', function () { tempDirs.length = 0; }); + test('keeps a detached shell running after an idle session loses all subscribers (mock LLM)', async function () { + this.timeout(180_000); + + const workspaceDir = await mkdtemp(`${tmpdir()}/test-mock-detached-release`); + tempDirs.push(workspaceDir); + const sessionUri = await createProviderSession(client, COPILOT_CONFIG, 'real-sdk-mock-detached-release', createdSessions, URI.file(workspaceDir)); + const turnId = 'turn-detached-release'; + + dispatchTurn(client, sessionUri, turnId, `[scenario:${DETACHED_SHELL_SCENARIO_ID}] Start the detached shell.`, 1); + const readyNotification = await client.waitForNotification(n => { + if (!isActionNotification(n, 'chat/toolCallReady')) { + return false; + } + return !(getActionEnvelope(n).action as ChatToolCallReadyAction).confirmed; + }, 90_000); + const readyEnvelope = getActionEnvelope(readyNotification); + const readyAction = readyEnvelope.action as ChatToolCallReadyAction; + client.dispatch({ + channel: readyEnvelope.channel, + clientSeq: 2, + action: { + type: ActionType.ChatToolCallConfirmed, + turnId: readyAction.turnId, + toolCallId: readyAction.toolCallId, + approved: true, + confirmed: ToolCallConfirmationReason.UserAction, + }, + }); + const completeNotification = await client.waitForNotification(n => isActionNotification(n, 'chat/toolCallComplete'), 90_000); + const completeAction = getActionEnvelope(completeNotification).action as ChatToolCallCompleteAction; + assert.match(JSON.stringify(completeAction.result), /detached background/); + await client.waitForNotification(n => isActionNotification(n, 'chat/turnComplete'), 90_000); + + const idle = await fetchSessionWithChat(client, sessionUri); + assert.deepStrictEqual({ + activeTurn: idle.activeTurn, + inProgress: (idle.status & SessionStatus.InProgress) !== 0, + }, { + activeTurn: undefined, + inProgress: false, + }); + + for (const channel of [buildDefaultChatUri(sessionUri), sessionUri]) { + client.notify('unsubscribe', { channel }); + } + await timeout(RELEASE_GRACE_MS + 1000); + + for (let attempt = 0; attempt < 150 && !existsSync(detachedCompletionMarker); attempt++) { + await timeout(100); + } + assert.strictEqual(await readFile(detachedCompletionMarker, 'utf8'), 'done'); + }); + test('releases an idle session and resumes it losslessly on re-subscribe (mock LLM)', async function () { this.timeout(180_000); From 0fc8be8db5bcbe012001f706ff3bfb1690eb5074 Mon Sep 17 00:00:00 2001 From: roblourens Date: Sat, 15 Aug 2026 14:53:58 -0700 Subject: [PATCH 8/8] Cache Agent Host session list metadata (#331040) * sessions: Cache Agent Host session metadata Persist pull request metadata and aggregate change counts so cached Agent Host sessions render complete list rows before the first live refresh. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: Clear cached change summaries Handle explicit change-summary clears so stale aggregate counts are not persisted and restored. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: Preserve summary clears over the wire Represent aggregate change-summary clears with an additive protocol marker and cover both transport serialization and metadata-only cache persistence. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: Keep cache change protocol-neutral Remove the locally invented summary-clear wire marker while retaining isolated coverage for change-stat and metadata-only cache persistence. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/AGENT_HOST_SESSIONS_PROVIDER.md | 2 +- .../browser/baseAgentHostSessionsProvider.ts | 32 +++++--- .../localAgentHostSessionsProvider.test.ts | 73 +++++++++++++++++++ 3 files changed, 96 insertions(+), 11 deletions(-) diff --git a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md index 425fa2ff313b3e..03c2e996285556 100644 --- a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md @@ -107,7 +107,7 @@ A single agent host session uses several distinct identifiers: To avoid an empty list on window startup — before the agent host has started, authentication has settled, and the first `listSessions()` round-trip returns — the base provider persists a lightweight snapshot of each session summary to `IStorageService` and re-hydrates it on the next launch. This machinery lives in `BaseAgentHostSessionsProvider` and is **shared by both the local and remote providers**: - A subclass opts in by calling `_enableSessionCachePersistence(storageKey)` at the end of its constructor (once the identity fields that `createAdapter` depends on are set). This hydrates persisted summaries into `_sessionCache` immediately, so `getSessions()` returns cached sessions before any live list. -- `createAdapter`/`updateAdapter` capture the source `IAgentSessionMetadata` in `_metaByRawId`; `onWillSaveState` lazily serializes the cache (overlaying mutable fields — title, `updatedAt`, `isRead`, `isArchived` — read from each adapter's observables), capped at the 100 most-recently-modified entries under `StorageScope.APPLICATION`. +- `createAdapter`/`updateAdapter` capture the source `IAgentSessionMetadata` in `_metaByRawId`; `onWillSaveState` lazily serializes the cache (overlaying mutable fields — title, `updatedAt`, `isRead`, `isArchived`, aggregate change counts, and the bounded, validated GitHub summary used for pull-request presentation — from each adapter), capped at the 100 most-recently-modified entries under `StorageScope.APPLICATION`. - Multi-root Editor sessions carry their originating workspace provenance in `_meta.multiRoot` as `{ workspaceFile }`. `workspaceFile` is the complete workspace configuration URI string; the Agent Host persists the validated object as JSON under the `multiRoot` session-database key, reconstructs it during listing/restoration, and the startup cache preserves it before the first live listing. The Editor session list matches this URI directly against `IWorkspace.configuration`; metadata-less sessions use current-folder containment without a separate workspace membership memento. - Multi-root new-session **Folder-picker** decisions are provider-owned and carried in `_meta` under the `vscode.folderPicker` key as `{ hidden, primary? }`. The owning agent computes it (`IAgent.computeFolderPickerDecision`) from the ordered working-directory set when a fresh (non-fork, non-import) multi-root session is created; `AgentService` seeds it into the session `_meta`, persists the validated object as JSON under the `vscode.folderPicker` session-database key, and reconstructs it during listing/restoration so the decision is a frozen creation-time fact (hidden stays hidden on reopen, shown stays shown). The client keeps the picker hidden by default and reveals it only when `hidden` is `false`, auto-selecting `primary` (a working-directory URI string, valid only on a hidden, pinned decision) before the session starts. A provider that expresses no opinion returns `undefined`, so nothing is seeded and the picker stays hidden. - Hydrated entries are reconciled against the authoritative `listSessions()` on the first successful `_refreshSessions()`: stale sessions that no longer exist are pruned. diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 480b0381952745..2b0f81ad6ea851 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -29,7 +29,7 @@ import type { IAgentSubscription } from '../../../../../platform/agentHost/commo import { ResolveSessionConfigResult, type SessionConfigPropertySchema } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { AgentCustomization, ChangesSummary, ChatInteractivity as ProtocolChatInteractivity, ChatOriginKind as ProtocolChatOriginKind, type ClientPluginCustomization, Customization, CustomizationEnablementKind, CustomizationType, type CustomizationEnablement, ModelSelection, SessionStatus as ProtocolSessionStatus, RootConfigState, RootState, SessionState, SessionSummary, type Changeset } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, isChatAction, isSessionAction, NotificationType } from '../../../../../platform/agentHost/common/state/sessionActions.js'; -import { AgentCapabilities, AgentInfo, buildChatUri, buildDefaultChatUri, getSessionRelatedPullRequestUrls, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, parseChatUri, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, ROOT_STATE_URI, SESSION_META_MULTI_ROOT_KEY, SessionMeta, SessionSourceControlOutcome, StateComponents, withSessionExternal, withSessionMultiRootMetadata, withSessionStatusFlag, withSessionWorkspaceless, type ChatSummary, type ISessionGitState, type ISessionMultiRootMetadata } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { AgentCapabilities, AgentInfo, buildChatUri, buildDefaultChatUri, getSessionRelatedPullRequestUrls, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, parseChatUri, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, ROOT_STATE_URI, SESSION_META_MULTI_ROOT_KEY, SessionMeta, SessionSourceControlOutcome, StateComponents, withSessionExternal, withSessionGitHubState, withSessionMultiRootMetadata, withSessionStatusFlag, withSessionWorkspaceless, type ChatSummary, type ISessionGitHubState, type ISessionGitState, type ISessionMultiRootMetadata } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; @@ -89,9 +89,8 @@ const CACHED_SESSIONS_MAX_PER_HOST = 100; /** * Serialized shape of an {@link IAgentSessionMetadata} suitable for - * persisting via {@link IStorageService}. URIs are stored as strings - * and diffs are intentionally omitted (they are re-populated when the - * connection refreshes sessions). + * persisting via {@link IStorageService}. URIs are stored as strings and only + * lightweight metadata needed to render the session list is retained. */ interface ISerializedSessionMetadata { readonly session: string; @@ -108,6 +107,8 @@ interface ISerializedSessionMetadata { /** @deprecated Legacy name for `isArchived`. */ readonly isDone?: boolean; readonly project?: { readonly uri: string; readonly displayName: string }; + readonly changes?: ChangesSummary; + readonly github?: ISessionGitHubState; /** * Whether the session is a workspace-less quick chat. Persisted because the * adapter seeds its session-kind from this tag at construction (see @@ -135,6 +136,8 @@ function serializeMetadata(meta: IAgentSessionMetadata): ISerializedSessionMetad workingDirectory: meta.workingDirectories?.[0]?.toString(), status: meta.status !== undefined ? meta.status & SESSION_STATUS_FLAG_MASK : undefined, project: meta.project ? { uri: meta.project.uri.toString(), displayName: meta.project.displayName } : undefined, + changes: meta.changes, + github: readSessionGitHubState(meta._meta), workspaceless: readSessionWorkspaceless(meta._meta) || undefined, external: readSessionExternal(meta._meta) || undefined, multiRoot: readSessionMultiRootMetadata(meta._meta), @@ -146,6 +149,7 @@ function deserializeMetadata(raw: ISerializedSessionMetadata): IAgentSessionMeta let _meta = withSessionWorkspaceless(undefined, raw.workspaceless === true); _meta = withSessionExternal(_meta, raw.external === true); _meta = withSessionMultiRootMetadata(_meta, readSessionMultiRootMetadata({ [SESSION_META_MULTI_ROOT_KEY]: raw.multiRoot })); + _meta = withSessionGitHubState(_meta, raw.github); return { session: URI.parse(raw.session), startTime: raw.startTime, @@ -154,6 +158,7 @@ function deserializeMetadata(raw: ISerializedSessionMetadata): IAgentSessionMeta workingDirectories: raw.workingDirectory ? [URI.parse(raw.workingDirectory)] : undefined, status: deserializeStatus(raw), project: raw.project ? { uri: URI.parse(raw.project.uri), displayName: raw.project.displayName } : undefined, + changes: raw.changes, ...(_meta ? { _meta } : {}), }; } catch { @@ -675,6 +680,8 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { // `reconcileSelectedAgent`). private _agentBaseDir: URI | undefined; private _meta: SessionMeta | undefined; + /** The latest session metadata used to build startup-cache presentation state. */ + get sessionMeta(): SessionMeta | undefined { return this._meta; } /** * Whether this session is a workspace-less quick chat. Seeded from the * constructor metadata and only ever promoted by @@ -1363,11 +1370,14 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { * yet. */ setMeta(meta: SessionMeta | undefined, tx?: ITransaction): boolean { + const metaChanged = !equals(this._meta, meta); this._meta = meta; - let didChange = false; + let didChange = metaChanged; subtransaction(tx, tx => { this._metaObs.set(this._meta, tx); - didChange = this._promoteToQuickChatIfWorkspaceless(tx); + if (this._promoteToQuickChatIfWorkspaceless(tx)) { + didChange = true; + } const workspace = this._computeWorkspace(); if (this._setWorkspace(workspace, tx)) { didChange = true; @@ -4818,20 +4828,22 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (!base) { continue; } + const sessionMeta = adapter.isQuickChat.get() + ? withSessionWorkspaceless(adapter.sessionMeta, true) + : adapter.sessionMeta; entries.push(serializeMetadata({ ...base, summary: adapter.title.get() || base.summary, modifiedTime: adapter.updatedAt.get().getTime(), + changes: adapter.changesSummary.get(), // A project assigned by `backfillProject` lives only on the adapter. project: adapter.project ?? base.project, status: withSessionStatusFlag( withSessionStatusFlag(base.status ?? ProtocolSessionStatus.Idle, ProtocolSessionStatus.IsRead, adapter.isRead.get()), ProtocolSessionStatus.IsArchived, adapter.isArchived.get()), - // The adapter's live kind wins over the snapshot: several metadata - // sources omit `_meta`, and persisting a stale one would resurrect - // the session as a workspace rooted at the host's scratch cwd. - ...(adapter.isQuickChat.get() ? { _meta: withSessionWorkspaceless(base._meta, true) } : {}), + // Session-state updates can refine presentation metadata without another listing. + _meta: sessionMeta, })); } if (entries.length === 0) { diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index ac95e75117f5f3..87d17d5cfeb24c 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -1289,6 +1289,79 @@ suite('LocalAgentHostSessionsProvider', () => { }); })); + test('hydrates persisted change stats before the live list is available', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const storageService = disposables.add(new InMemoryStorageService()); + const previousHost = new MockAgentHostService(); + disposables.add(toDisposable(() => previousHost.dispose())); + previousHost.addSession(createSession('cached-metadata', { summary: 'Cached Metadata' })); + createProvider(disposables, previousHost, undefined, { storageService }); + await timeout(0); + await storageService.flush(); + + fireSessionSummaryChanged(previousHost, 'cached-metadata', { + changes: { additions: 12, deletions: 4, files: 3 }, + }); + await storageService.flush(); + + const nextHost = new MockAgentHostService(); + disposables.add(toDisposable(() => nextHost.dispose())); + nextHost.setAuthenticationPending(true); + const nextProvider = createProvider(disposables, nextHost, undefined, { storageService }); + const listSessionsCallsBeforeRead = nextHost.listSessionsCallCount; + const restored = nextProvider.getSessions()[0]; + + assert.deepStrictEqual({ + listSessionsCallsBeforeRead, + changesSummary: restored.changesSummary?.get(), + }, { + listSessionsCallsBeforeRead: 0, + changesSummary: { additions: 12, deletions: 4, files: 3 }, + }); + })); + + test('hydrates a pull request icon persisted by a metadata-only update', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const storageService = disposables.add(new InMemoryStorageService()); + const previousHost = new MockAgentHostService(); + disposables.add(toDisposable(() => previousHost.dispose())); + previousHost.addSession(createSession('cached-pr', { + summary: 'Cached PR', + project: { uri: URI.file('/repo'), displayName: 'repo' }, + })); + createProvider(disposables, previousHost, undefined, { storageService }); + await timeout(0); + await storageService.flush(); + + fireSessionSummaryChanged(previousHost, 'cached-pr', { + _meta: withSessionGitHubState(undefined, { + owner: 'owner', + repo: 'repo', + pullRequestUrls: ['https://github.com/owner/repo/pull/42'], + pullRequestBranchName: 'feature', + }), + }); + await storageService.flush(); + + const nextHost = new MockAgentHostService(); + disposables.add(toDisposable(() => nextHost.dispose())); + nextHost.setAuthenticationPending(true); + const gitHubService = new class extends mock() { + private readonly _model = { pullRequest: constObservable(undefined) } as unknown as GitHubPullRequestModel; + override createPullRequestModelReference = () => new ImmortalReference(this._model); + }(); + const nextProvider = createProvider(disposables, nextHost, undefined, { storageService, gitHubService }); + const restored = nextProvider.getSessions()[0]; + const pullRequestIcon = restored.completedStateIcon?.get(); + + assert.deepStrictEqual({ + pullRequestIcon: pullRequestIcon && { id: pullRequestIcon.id, color: pullRequestIcon.color?.id }, + }, { + pullRequestIcon: { + id: computePullRequestIcon(GitHubPullRequestState.Open).id, + color: computePullRequestIcon(GitHubPullRequestState.Open).color?.id, + }, + }); + })); + test('discards a legacy cache entry so read state is rebuilt from the host', () => runWithFakedTimers({ useFakeTimers: true }, async () => { // Storage-key literals of the pre-`.v2` cache schema, whose entries // carried a stale `isRead: true` written by the old always-read adapter.