diff --git a/.github/rulesets/docs-internal/main-branch-protection.json b/.github/rulesets/docs-internal/main-branch-protection.json index a51eabfb9267..98eb5f7d6e6c 100644 --- a/.github/rulesets/docs-internal/main-branch-protection.json +++ b/.github/rulesets/docs-internal/main-branch-protection.json @@ -124,6 +124,10 @@ "context": "early-access", "integration_id": 15368 }, + { + "context": "eslint-rules", + "integration_id": 15368 + }, { "context": "events", "integration_id": 15368 diff --git a/.github/rulesets/docs/main-branch-protection.json b/.github/rulesets/docs/main-branch-protection.json index cbec8a04f3c5..04200c00a36a 100644 --- a/.github/rulesets/docs/main-branch-protection.json +++ b/.github/rulesets/docs/main-branch-protection.json @@ -105,6 +105,10 @@ "context": "early-access", "integration_id": 15368 }, + { + "context": "eslint-rules", + "integration_id": 15368 + }, { "context": "events", "integration_id": 15368 diff --git a/.github/workflows/article-api-docs.yml b/.github/workflows/article-api-docs.yml index 1243e53a5e25..551c28ed55c0 100644 --- a/.github/workflows/article-api-docs.yml +++ b/.github/workflows/article-api-docs.yml @@ -8,13 +8,16 @@ on: paths: - 'src/article-api/middleware/article.ts' - 'src/article-api/middleware/pagelist.ts' + - 'src/article-api/scripts/generate-api-docs.ts' + # Manual edits to generated docs must fail this check. + - 'src/article-api/README.md' - .github/workflows/article-api-docs.yml permissions: contents: read jobs: - check-content-linter-rules-docs: + check-article-api-docs: runs-on: ubuntu-latest if: github.repository == 'github/docs-internal' || github.repository == 'github/docs' steps: diff --git a/.github/workflows/sync-sdk-docs.yml b/.github/workflows/sync-sdk-docs.yml index 5eb720958ec6..63276a6e6537 100644 --- a/.github/workflows/sync-sdk-docs.yml +++ b/.github/workflows/sync-sdk-docs.yml @@ -100,7 +100,7 @@ jobs: # Keep relocated pages in the rsync input. RELOCATED_PAGES in # src/workflows/sync-sdk-docs/normalize-sdk-docs.ts removes them and # repoints inbound links. - rsync -av --exclude='.validation/' --exclude='developer-docs/' "$SDK_TMP/docs/" "$SDK_DOCS_TARGET/" + rsync -av --exclude='.validation/' --exclude='developer-docs/' --exclude='AGENTS.md' --exclude='/runtime-supervised-host.md' "$SDK_TMP/docs/" "$SDK_DOCS_TARGET/" echo "Copied $(find "$SDK_DOCS_TARGET" -name '*.md' | wc -l | tr -d ' ') markdown files" - name: Normalize content diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 83dc6a78588c..e9ab7491ebbd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,7 +31,7 @@ jobs: matrix: # Add new matrix suites to branch protection required checks too. name: - # Lists every src directory. Commented-out entries have no suite-specific tests. + # Lists every src directory. Commented-out entries have no tests unless noted. # - app - archives - article-api @@ -48,7 +48,7 @@ jobs: # - deployments # - dev-toc - early-access - # - eslint-rules + - eslint-rules - events - fixtures - frame @@ -85,7 +85,7 @@ jobs: env: # These suites make no HTTP requests, so they skip the Next.js build and test server. # A suite that adds an HTTP test fails with a connection error until it's removed from this list. - START_VITEST_SERVER: ${{ contains(fromJSON('["color-schemes", "content-linter", "data-directory", "ghes-releases", "journeys", "links", "observability", "products", "tools", "workflows"]'), matrix.name) && 'false' || 'true' }} + START_VITEST_SERVER: ${{ contains(fromJSON('["color-schemes", "content-linter", "data-directory", "eslint-rules", "ghes-releases", "journeys", "links", "observability", "products", "tools", "workflows"]'), matrix.name) && 'false' || 'true' }} steps: # Repeat each if on its step so skipped work still leaves the required check present. @@ -171,8 +171,9 @@ jobs: run: npm run build - uses: ./.github/actions/warmup-remotejson-cache - # Only routing tests cover archived enterprise server URLs. - if: ${{ matrix.name == 'redirects' }} + # The archives and redirects suites request archived GHES redirects.json files. + # keep-caches-warm.yml seeds this cache in docs-internal only; a cold warmup fetches every archived version. + if: ${{ matrix.name == 'redirects' || (matrix.name == 'archives' && github.repository == 'github/docs-internal') }} - uses: ./.github/actions/setup-elasticsearch if: ${{ matrix.name == 'search' || matrix.name == 'languages' }} @@ -197,4 +198,5 @@ jobs: VITEST_FLAGS: ${{ matrix.name == 'article-api' && '--no-file-parallelism --maxWorkers=1' || '' }} # PRs render pinned autogenerated pages unless they change pipeline code or output. AUTOGENERATED_RENDER_ALL: ${{ matrix.name == 'automated-pipelines' && (github.event_name != 'pull_request' || steps.pipeline_files.outputs.any_modified == 'true') }} - run: npm test -- $VITEST_FLAGS src/${{ matrix.name }}/tests/ + # Filter on the whole src directory so nested tests/ folders, like src/webhooks/lib/tests/, run too. + run: npm test -- $VITEST_FLAGS src/${{ matrix.name }}/ diff --git a/assets/images/help/copilot/copilot-sdk/features-agent-loop-diagram-0.png b/assets/images/help/copilot/copilot-sdk/features-agent-loop-diagram-0.png index 3a379ea3d09d..7f5681ea6c41 100644 Binary files a/assets/images/help/copilot/copilot-sdk/features-agent-loop-diagram-0.png and b/assets/images/help/copilot/copilot-sdk/features-agent-loop-diagram-0.png differ diff --git a/assets/images/help/copilot/copilot-sdk/features-agent-loop-diagram-1.png b/assets/images/help/copilot/copilot-sdk/features-agent-loop-diagram-1.png index c4c332d8a203..a3368091c6f9 100644 Binary files a/assets/images/help/copilot/copilot-sdk/features-agent-loop-diagram-1.png and b/assets/images/help/copilot/copilot-sdk/features-agent-loop-diagram-1.png differ diff --git a/assets/images/help/copilot/copilot-sdk/features-agent-loop-diagram-2.png b/assets/images/help/copilot/copilot-sdk/features-agent-loop-diagram-2.png index 870e767a59a3..c40d5372de3d 100644 Binary files a/assets/images/help/copilot/copilot-sdk/features-agent-loop-diagram-2.png and b/assets/images/help/copilot/copilot-sdk/features-agent-loop-diagram-2.png differ diff --git a/assets/images/help/copilot/copilot-sdk/features-custom-agents-diagram-0.png b/assets/images/help/copilot/copilot-sdk/features-custom-agents-diagram-0.png index aefc43a58049..4377dcc67d56 100644 Binary files a/assets/images/help/copilot/copilot-sdk/features-custom-agents-diagram-0.png and b/assets/images/help/copilot/copilot-sdk/features-custom-agents-diagram-0.png differ diff --git a/assets/images/help/copilot/copilot-sdk/features-hooks-diagram-0.png b/assets/images/help/copilot/copilot-sdk/features-hooks-diagram-0.png index 8977ab9a3640..1c79d30e3783 100644 Binary files a/assets/images/help/copilot/copilot-sdk/features-hooks-diagram-0.png and b/assets/images/help/copilot/copilot-sdk/features-hooks-diagram-0.png differ diff --git a/assets/images/help/copilot/copilot-sdk/features-image-input-diagram-0.png b/assets/images/help/copilot/copilot-sdk/features-image-input-diagram-0.png index 40a2180139cb..818064b9dfca 100644 Binary files a/assets/images/help/copilot/copilot-sdk/features-image-input-diagram-0.png and b/assets/images/help/copilot/copilot-sdk/features-image-input-diagram-0.png differ diff --git a/assets/images/help/copilot/copilot-sdk/features-session-persistence-diagram-0.png b/assets/images/help/copilot/copilot-sdk/features-session-persistence-diagram-0.png index 4059718e7a4e..8819ab8a24c5 100644 Binary files a/assets/images/help/copilot/copilot-sdk/features-session-persistence-diagram-0.png and b/assets/images/help/copilot/copilot-sdk/features-session-persistence-diagram-0.png differ diff --git a/assets/images/help/copilot/copilot-sdk/features-session-persistence-diagram-1.png b/assets/images/help/copilot/copilot-sdk/features-session-persistence-diagram-1.png index 6888919cd14d..4de01e043154 100644 Binary files a/assets/images/help/copilot/copilot-sdk/features-session-persistence-diagram-1.png and b/assets/images/help/copilot/copilot-sdk/features-session-persistence-diagram-1.png differ diff --git a/assets/images/help/copilot/copilot-sdk/features-session-persistence-diagram-2.png b/assets/images/help/copilot/copilot-sdk/features-session-persistence-diagram-2.png index 5c7e17a5b4a4..1ef6584c2c54 100644 Binary files a/assets/images/help/copilot/copilot-sdk/features-session-persistence-diagram-2.png and b/assets/images/help/copilot/copilot-sdk/features-session-persistence-diagram-2.png differ diff --git a/assets/images/help/copilot/copilot-sdk/features-session-persistence-diagram-3.png b/assets/images/help/copilot/copilot-sdk/features-session-persistence-diagram-3.png index d1fb1cd181ae..88f7ed908de6 100644 Binary files a/assets/images/help/copilot/copilot-sdk/features-session-persistence-diagram-3.png and b/assets/images/help/copilot/copilot-sdk/features-session-persistence-diagram-3.png differ diff --git a/assets/images/help/copilot/copilot-sdk/features-session-persistence-diagram-4.png b/assets/images/help/copilot/copilot-sdk/features-session-persistence-diagram-4.png index f185a167d3e0..5cffee4a05dd 100644 Binary files a/assets/images/help/copilot/copilot-sdk/features-session-persistence-diagram-4.png and b/assets/images/help/copilot/copilot-sdk/features-session-persistence-diagram-4.png differ diff --git a/assets/images/help/copilot/copilot-sdk/features-session-persistence-diagram-5.png b/assets/images/help/copilot/copilot-sdk/features-session-persistence-diagram-5.png index 9aa9ae1e321d..29375da1746b 100644 Binary files a/assets/images/help/copilot/copilot-sdk/features-session-persistence-diagram-5.png and b/assets/images/help/copilot/copilot-sdk/features-session-persistence-diagram-5.png differ diff --git a/assets/images/help/copilot/copilot-sdk/features-steering-and-queueing-diagram-0.png b/assets/images/help/copilot/copilot-sdk/features-steering-and-queueing-diagram-0.png index b6e8e3a92b70..f935a08c5ca5 100644 Binary files a/assets/images/help/copilot/copilot-sdk/features-steering-and-queueing-diagram-0.png and b/assets/images/help/copilot/copilot-sdk/features-steering-and-queueing-diagram-0.png differ diff --git a/assets/images/help/copilot/copilot-sdk/features-streaming-events-diagram-0.png b/assets/images/help/copilot/copilot-sdk/features-streaming-events-diagram-0.png index a25d9551a309..eca16d801fe8 100644 Binary files a/assets/images/help/copilot/copilot-sdk/features-streaming-events-diagram-0.png and b/assets/images/help/copilot/copilot-sdk/features-streaming-events-diagram-0.png differ diff --git a/assets/images/help/copilot/copilot-sdk/setup-azure-managed-identity-diagram-0.png b/assets/images/help/copilot/copilot-sdk/setup-azure-managed-identity-diagram-0.png index fe12e2592949..97e4f4738aa8 100644 Binary files a/assets/images/help/copilot/copilot-sdk/setup-azure-managed-identity-diagram-0.png and b/assets/images/help/copilot/copilot-sdk/setup-azure-managed-identity-diagram-0.png differ diff --git a/assets/images/help/copilot/copilot-sdk/setup-backend-services-diagram-0.png b/assets/images/help/copilot/copilot-sdk/setup-backend-services-diagram-0.png index 729ef9c15f84..26799683e830 100644 Binary files a/assets/images/help/copilot/copilot-sdk/setup-backend-services-diagram-0.png and b/assets/images/help/copilot/copilot-sdk/setup-backend-services-diagram-0.png differ diff --git a/assets/images/help/copilot/copilot-sdk/setup-backend-services-diagram-1.png b/assets/images/help/copilot/copilot-sdk/setup-backend-services-diagram-1.png index 65772b45a41b..1a15c3e4b917 100644 Binary files a/assets/images/help/copilot/copilot-sdk/setup-backend-services-diagram-1.png and b/assets/images/help/copilot/copilot-sdk/setup-backend-services-diagram-1.png differ diff --git a/assets/images/help/copilot/copilot-sdk/setup-backend-services-diagram-2.png b/assets/images/help/copilot/copilot-sdk/setup-backend-services-diagram-2.png index d9ffefc7fd1a..7f1b7d82f78c 100644 Binary files a/assets/images/help/copilot/copilot-sdk/setup-backend-services-diagram-2.png and b/assets/images/help/copilot/copilot-sdk/setup-backend-services-diagram-2.png differ diff --git a/assets/images/help/copilot/copilot-sdk/setup-backend-services-diagram-3.png b/assets/images/help/copilot/copilot-sdk/setup-backend-services-diagram-3.png index 2939f568dbd4..08087537ed45 100644 Binary files a/assets/images/help/copilot/copilot-sdk/setup-backend-services-diagram-3.png and b/assets/images/help/copilot/copilot-sdk/setup-backend-services-diagram-3.png differ diff --git a/assets/images/help/copilot/copilot-sdk/setup-backend-services-diagram-4.png b/assets/images/help/copilot/copilot-sdk/setup-backend-services-diagram-4.png index 5534eba3093c..cfd5f31b9193 100644 Binary files a/assets/images/help/copilot/copilot-sdk/setup-backend-services-diagram-4.png and b/assets/images/help/copilot/copilot-sdk/setup-backend-services-diagram-4.png differ diff --git a/assets/images/help/copilot/copilot-sdk/setup-bundled-cli-diagram-0.png b/assets/images/help/copilot/copilot-sdk/setup-bundled-cli-diagram-0.png index 244240a6e3d9..65c69ff8116e 100644 Binary files a/assets/images/help/copilot/copilot-sdk/setup-bundled-cli-diagram-0.png and b/assets/images/help/copilot/copilot-sdk/setup-bundled-cli-diagram-0.png differ diff --git a/assets/images/help/copilot/copilot-sdk/setup-bundled-cli-diagram-1.png b/assets/images/help/copilot/copilot-sdk/setup-bundled-cli-diagram-1.png index 8d7da10e9d0e..b66a11cbc8a5 100644 Binary files a/assets/images/help/copilot/copilot-sdk/setup-bundled-cli-diagram-1.png and b/assets/images/help/copilot/copilot-sdk/setup-bundled-cli-diagram-1.png differ diff --git a/assets/images/help/copilot/copilot-sdk/setup-choosing-a-setup-path-diagram-0.png b/assets/images/help/copilot/copilot-sdk/setup-choosing-a-setup-path-diagram-0.png index 8d1ed7b19f4c..e87856417f3e 100644 Binary files a/assets/images/help/copilot/copilot-sdk/setup-choosing-a-setup-path-diagram-0.png and b/assets/images/help/copilot/copilot-sdk/setup-choosing-a-setup-path-diagram-0.png differ diff --git a/assets/images/help/copilot/copilot-sdk/setup-choosing-a-setup-path-diagram-1.png b/assets/images/help/copilot/copilot-sdk/setup-choosing-a-setup-path-diagram-1.png index 7bfe8df4e916..a813df0bb284 100644 Binary files a/assets/images/help/copilot/copilot-sdk/setup-choosing-a-setup-path-diagram-1.png and b/assets/images/help/copilot/copilot-sdk/setup-choosing-a-setup-path-diagram-1.png differ diff --git a/assets/images/help/copilot/copilot-sdk/setup-github-oauth-diagram-0.png b/assets/images/help/copilot/copilot-sdk/setup-github-oauth-diagram-0.png index b2f39cdc066e..6a099518b49d 100644 Binary files a/assets/images/help/copilot/copilot-sdk/setup-github-oauth-diagram-0.png and b/assets/images/help/copilot/copilot-sdk/setup-github-oauth-diagram-0.png differ diff --git a/assets/images/help/copilot/copilot-sdk/setup-github-oauth-diagram-1.png b/assets/images/help/copilot/copilot-sdk/setup-github-oauth-diagram-1.png index 4cee0e66d4d5..926e5e38258b 100644 Binary files a/assets/images/help/copilot/copilot-sdk/setup-github-oauth-diagram-1.png and b/assets/images/help/copilot/copilot-sdk/setup-github-oauth-diagram-1.png differ diff --git a/assets/images/help/copilot/copilot-sdk/setup-github-oauth-diagram-2.png b/assets/images/help/copilot/copilot-sdk/setup-github-oauth-diagram-2.png index 0cbac6b8a222..e892e3584ad0 100644 Binary files a/assets/images/help/copilot/copilot-sdk/setup-github-oauth-diagram-2.png and b/assets/images/help/copilot/copilot-sdk/setup-github-oauth-diagram-2.png differ diff --git a/assets/images/help/copilot/copilot-sdk/setup-github-oauth-diagram-3.png b/assets/images/help/copilot/copilot-sdk/setup-github-oauth-diagram-3.png index f5342028abed..271a4df06c68 100644 Binary files a/assets/images/help/copilot/copilot-sdk/setup-github-oauth-diagram-3.png and b/assets/images/help/copilot/copilot-sdk/setup-github-oauth-diagram-3.png differ diff --git a/assets/images/help/copilot/copilot-sdk/setup-in-process-runtime-diagram-0.png b/assets/images/help/copilot/copilot-sdk/setup-in-process-runtime-diagram-0.png index 909feda77a0a..d4209220f965 100644 Binary files a/assets/images/help/copilot/copilot-sdk/setup-in-process-runtime-diagram-0.png and b/assets/images/help/copilot/copilot-sdk/setup-in-process-runtime-diagram-0.png differ diff --git a/assets/images/help/copilot/copilot-sdk/setup-local-cli-diagram-0.png b/assets/images/help/copilot/copilot-sdk/setup-local-cli-diagram-0.png index a50637ed0907..486433e93f44 100644 Binary files a/assets/images/help/copilot/copilot-sdk/setup-local-cli-diagram-0.png and b/assets/images/help/copilot/copilot-sdk/setup-local-cli-diagram-0.png differ diff --git a/assets/images/help/copilot/copilot-sdk/setup-multi-tenancy-diagram-0.png b/assets/images/help/copilot/copilot-sdk/setup-multi-tenancy-diagram-0.png index 57d30960d07d..36cf671d1297 100644 Binary files a/assets/images/help/copilot/copilot-sdk/setup-multi-tenancy-diagram-0.png and b/assets/images/help/copilot/copilot-sdk/setup-multi-tenancy-diagram-0.png differ diff --git a/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-0.png b/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-0.png index a20cb534ffcc..ad5a7df5394f 100644 Binary files a/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-0.png and b/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-0.png differ diff --git a/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-1.png b/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-1.png index 0800d1cdd92b..ccb93af55442 100644 Binary files a/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-1.png and b/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-1.png differ diff --git a/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-2.png b/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-2.png index dac8d11840b4..9cba31d20d7a 100644 Binary files a/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-2.png and b/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-2.png differ diff --git a/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-3.png b/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-3.png index 35e4eba0f1d8..22f0fc217983 100644 Binary files a/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-3.png and b/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-3.png differ diff --git a/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-4.png b/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-4.png index 10bfc928dd2b..517b138c0482 100644 Binary files a/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-4.png and b/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-4.png differ diff --git a/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-5.png b/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-5.png index ae45f94d8d4d..368e7917f0da 100644 Binary files a/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-5.png and b/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-5.png differ diff --git a/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-6.png b/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-6.png index 444923ffcefc..ebbd7c38928d 100644 Binary files a/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-6.png and b/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-6.png differ diff --git a/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-7.png b/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-7.png index 29f4ffc4e59e..570858ce8a5e 100644 Binary files a/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-7.png and b/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-7.png differ diff --git a/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-8.png b/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-8.png index 40aaacd468f5..3aad7fb4c942 100644 Binary files a/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-8.png and b/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-8.png differ diff --git a/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-9.png b/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-9.png index 7a1396e621ef..537433fa02db 100644 Binary files a/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-9.png and b/assets/images/help/copilot/copilot-sdk/setup-scaling-diagram-9.png differ diff --git a/content/actions/how-tos/manage-workflow-runs/disable-and-enable-workflows.md b/content/actions/how-tos/manage-workflow-runs/disable-and-enable-workflows.md index 212ddde63425..02020cc6e0e2 100644 --- a/content/actions/how-tos/manage-workflow-runs/disable-and-enable-workflows.md +++ b/content/actions/how-tos/manage-workflow-runs/disable-and-enable-workflows.md @@ -20,6 +20,8 @@ contentType: how-tos Disabling a workflow allows you to stop a workflow from being triggered without having to delete the file from the repo. You can easily re-enable the workflow again on {% data variables.product.prodname_dotcom %}. +Disabling a workflow doesn't cancel its existing runs, but you can't re-run those runs or their jobs while the workflow is disabled. Enable the workflow first to re-run it. + Temporarily disabling a workflow can be useful in many scenarios. These are a few examples where disabling a workflow might be helpful: * A workflow error that produces too many or wrong requests, impacting external services negatively. diff --git a/content/actions/how-tos/manage-workflow-runs/re-run-workflows-and-jobs.md b/content/actions/how-tos/manage-workflow-runs/re-run-workflows-and-jobs.md index c3ac08f3936b..6b8e35527446 100644 --- a/content/actions/how-tos/manage-workflow-runs/re-run-workflows-and-jobs.md +++ b/content/actions/how-tos/manage-workflow-runs/re-run-workflows-and-jobs.md @@ -21,6 +21,8 @@ Re-runs use the privileges of the actor who initially triggered the workflow, no A workflow run can be re-run a maximum of 50 times. This limit includes both full re-runs and re-runs of a subset of jobs. +You can't re-run a workflow run, or any of its jobs, while the workflow is disabled. You need to enable the workflow first. See [AUTOTITLE](/actions/how-tos/manage-workflow-runs/disable-and-enable-workflows). + ## Re-running all the jobs in a workflow {% webui %} diff --git a/content/copilot/how-tos/copilot-sdk/auth/byok.md b/content/copilot/how-tos/copilot-sdk/auth/byok.md index 9c61843c1052..914814c82e1a 100644 --- a/content/copilot/how-tos/copilot-sdk/auth/byok.md +++ b/content/copilot/how-tos/copilot-sdk/auth/byok.md @@ -212,8 +212,9 @@ client.stop().get(); | `type` | `"openai"` \| `"azure"` \| `"anthropic"` | Provider type (default: `"openai"`) | | `baseUrl` / `base_url` | string | **Required.** API endpoint URL | | `apiKey` / `api_key` | string | API key (optional for local providers like Ollama) | +| `apiKeyCommand` / `api_key_command` | string | Command run by the runtime before each provider request to obtain a fresh API key. Also exposed as `ApiKeyCommand` in .NET and `APIKeyCommand` in Go. Credential-command failures fail the turn before sending a model request. | | `bearerToken` / `bearer_token` | string | Bearer token auth (takes precedence over apiKey) | -| `bearerTokenProvider` / `bearer_token_provider` | callback | Returns a bearer token on demand (takes precedence over `apiKey` and `bearerToken`) | +| `bearerTokenProvider` / `bearer_token_provider` | callback | Returns a bearer token on demand (takes precedence over `apiKey`, `apiKeyCommand`, and `bearerToken`; the command does not run) | | `wireApi` / `wire_api` | `"completions"` \| `"responses"` | Select `"completions"` for broad model compatibility (the Chat Completions API); select `"responses"` for multi-turn state management, tool namespacing, and reasoning support (the Responses API). Anthropic models always use the Messages API regardless of this setting. | | `azure.apiVersion` / `azure.api_version` | string | Azure API version. When set, the runtime uses the versioned deployment route; when omitted, it uses the GA versionless `v1` route. | diff --git a/content/copilot/how-tos/copilot-sdk/auth/server-to-server-tokens.md b/content/copilot/how-tos/copilot-sdk/auth/server-to-server-tokens.md index 89e67caeea35..3e62fdd458d7 100644 --- a/content/copilot/how-tos/copilot-sdk/auth/server-to-server-tokens.md +++ b/content/copilot/how-tos/copilot-sdk/auth/server-to-server-tokens.md @@ -1,6 +1,6 @@ --- title: Server-to-server authentication -shortTitle: Server-to-server tokens +shortTitle: Server To Server Tokens intro: >- Use a short-lived installation access token when a service needs to make Copilot requests on behalf of an organization without a user's credentials. In diff --git a/content/copilot/how-tos/copilot-sdk/features/changing-tools.md b/content/copilot/how-tos/copilot-sdk/features/changing-tools.md new file mode 100644 index 000000000000..168eedd5265d --- /dev/null +++ b/content/copilot/how-tos/copilot-sdk/features/changing-tools.md @@ -0,0 +1,192 @@ +--- +title: Changing tools during a session +shortTitle: Changing Tools +intro: >- + An application can replace the tools it supplies to a live session without + recreating it. For example, a web page can offer different tools as the user + navigates, or a client that takes over a session can bring its own tools. Each + SDK replaces the client's complete tool set and the handlers that serve it in + one call. +versions: + fpt: '*' + ghec: '*' +contentType: how-tos +--- + + + + +> **Experimental.** Tool replacement wraps the experimental `session.tools.set` RPC and requires a runtime that supports it. + +## Replace this client's tools + +Pass the complete set of tools this client should supply. It replaces the tools the client supplied when the session was created or resumed, or in an earlier replacement. Built-in, MCP, and plugin tools, and tools that other clients connected to the same session supply, are unaffected. Pass an empty list to remove all of this client's tools. + +Tools are defined exactly as they are for creating a session, and a tool without a handler behaves the same way it does there. + +{% codetabs %} +{% codetab typescript %} + +```typescript +import { defineTool } from "@github/copilot-sdk"; +import { z } from "zod"; + +const searchIssues = defineTool("search_issues", { + description: "Search the issues shown on the current page", + parameters: z.object({ query: z.string().describe("Search text") }), + handler: async ({ query }) => findIssues(query), +}); + +// Supply only the tools the current page provides. +await session.setTools([searchIssues]); + +// Remove all of this client's tools. +await session.setTools([]); +``` + +{% endcodetab %} +{% codetab python %} + +```python +from pydantic import BaseModel, Field +from copilot.tools import define_tool + +class SearchIssuesParams(BaseModel): + query: str = Field(description="Search text") + +@define_tool(description="Search the issues shown on the current page") +async def search_issues(params: SearchIssuesParams) -> str: + return await find_issues(params.query) + +# Supply only the tools the current page provides. +await session.set_tools([search_issues]) + +# Remove all of this client's tools. +await session.set_tools([]) +``` + +{% endcodetab %} +{% codetab go %} + +```golang +type SearchIssuesParams struct { + Query string `json:"query" jsonschema:"Search text"` +} + +searchIssues := copilot.DefineTool( + "search_issues", + "Search the issues shown on the current page", + func(params SearchIssuesParams, inv copilot.ToolInvocation) (string, error) { + return findIssues(params.Query) + }, +) + +// Supply only the tools the current page provides. +if err := session.SetTools(ctx, []copilot.Tool{searchIssues}); err != nil { + log.Fatal(err) +} + +// Remove all of this client's tools. +if err := session.SetTools(ctx, nil); err != nil { + log.Fatal(err) +} +``` + +{% endcodetab %} +{% codetab dotnet %} + +```csharp +var searchIssues = CopilotTool.DefineTool( + ([Description("Search text")] string query) => FindIssues(query), + factoryOptions: new AIFunctionFactoryOptions + { + Name = "search_issues", + Description = "Search the issues shown on the current page", + }); + +// Supply only the tools the current page provides. +await session.SetToolsAsync([searchIssues]); + +// Remove all of this client's tools. +await session.SetToolsAsync([]); +``` + +`SetToolsAsync` raises the `GHCP001` experimental diagnostic, which you suppress with `#pragma warning disable GHCP001` or a project-level `GHCP001`. + +{% endcodetab %} +{% codetab java %} + +```java +var searchIssues = ToolDefinition.create( + "search_issues", + "Search the issues shown on the current page", + Map.of( + "type", "object", + "properties", Map.of("query", Map.of("type", "string", "description", "Search text")), + "required", List.of("query")), + invocation -> CompletableFuture.completedFuture( + findIssues((String) invocation.getArguments().get("query")))); + +// Supply only the tools the current page provides. +session.setTools(List.of(searchIssues)).get(); + +// Remove all of this client's tools. +session.setTools(List.of()).get(); +``` + +`setTools` is `@CopilotExperimental`, so the consuming class or method must opt in with `@AllowCopilotExperimental`. See [Using experimental APIs](https://github.com/github/copilot-sdk/tree/main/java/README.md#using-experimental-apis). + +{% endcodetab %} +{% codetab rust %} + + + +```rust +#[derive(Deserialize, JsonSchema)] +struct SearchIssuesParams { + /// Search text + query: String, +} + +let search_issues = define_tool( + "search_issues", + "Search the issues shown on the current page", + |_inv, params: SearchIssuesParams| async move { + Ok(ToolResult::Text(find_issues(¶ms.query).await)) + }, +); + +// Supply only the tools the current page provides. +session.set_tools(vec![search_issues]).await?; + +// Remove all of this client's tools. +session.set_tools(Vec::new()).await?; +``` + +{% endcodetab %} +{% endcodetabs %} + +## When the new tools take effect + +- **Model requests.** The runtime offers the new tools from the agent's next model request. That request can be part of a turn that is already in progress. +- **Handlers.** As soon as the runtime accepts the replacement, every tool call the session dispatches uses the new handlers. Calls that are already running finish on the handlers that started them. +- **Rejection.** If the runtime rejects the replacement, the call fails and the previous tools and handlers stay in place. The runtime rejects tool names with invalid characters and names that another connected client already supplies. +- **Ordering.** Concurrent replacements on the same session are applied one at a time, in the order they're made. +- **Cancellation.** In SDKs that let you cancel the call, cancelling it while an earlier replacement is still in flight sends nothing. Once the request is sent, cancelling only stops the wait: if the runtime accepts the replacement, the new handlers still take effect. + +## Replacing tools during a turn + +A model request that is already in flight was made with the previous tools, so the agent can still call a tool you just removed. The session doesn't answer that call, because another connected client might supply a tool with the same name, so the call can stay pending until the turn is aborted. + +If a running turn might still call a tool you remove, replace tools while the session is idle, for example after the `session.idle` event, or abort the turn first. Replacing a tool's handler under the same name is safe during a turn: calls dispatched after the runtime accepts the replacement use the new handler. + +## SDK reference + +| SDK | Method | +|---|---| +| TypeScript | `session.setTools(tools)` | +| Python | `await session.set_tools(tools)` | +| Go | `session.SetTools(ctx, tools)` | +| .NET | `await session.SetToolsAsync(tools, cancellationToken)` | +| Java | `session.setTools(tools)`, which returns a `CompletableFuture` | +| Rust | `session.set_tools(tools).await` | diff --git a/content/copilot/how-tos/copilot-sdk/features/client-info.md b/content/copilot/how-tos/copilot-sdk/features/client-info.md index 9f4e932dc328..b4caeb3f65ee 100644 --- a/content/copilot/how-tos/copilot-sdk/features/client-info.md +++ b/content/copilot/how-tos/copilot-sdk/features/client-info.md @@ -1,6 +1,6 @@ --- title: Client info -shortTitle: Client info +shortTitle: Client Info intro: >- Client info identifies the application using the Copilot SDK and, when applicable, a specific integration within it. An integration is an diff --git a/content/copilot/how-tos/copilot-sdk/features/context-management.md b/content/copilot/how-tos/copilot-sdk/features/context-management.md index bc5850860094..064c9593ae81 100644 --- a/content/copilot/how-tos/copilot-sdk/features/context-management.md +++ b/content/copilot/how-tos/copilot-sdk/features/context-management.md @@ -1,6 +1,6 @@ --- title: Context clearing and terminal tools -shortTitle: Context management +shortTitle: Context Management intro: >- Use `session.history.clearContext` when a host needs to replace the current conversation context without replacing the session. Typical uses include diff --git a/content/copilot/how-tos/copilot-sdk/features/custom-agents.md b/content/copilot/how-tos/copilot-sdk/features/custom-agents.md index e19d10b7a18f..505e107db0af 100644 --- a/content/copilot/how-tos/copilot-sdk/features/custom-agents.md +++ b/content/copilot/how-tos/copilot-sdk/features/custom-agents.md @@ -31,6 +31,8 @@ Custom agents are lightweight agent definitions you attach to a session. Each ag ## Defining custom agents +To discover Markdown definitions from versioned packages or vendored folders, use `agentDirectories` or [AUTOTITLE](/copilot/how-tos/copilot-sdk/features/customization-locations). + Pass `customAgents` when creating a session. Each agent needs at minimum a `name` and `prompt`. {% codetabs %} diff --git a/content/copilot/how-tos/copilot-sdk/features/customization-locations.md b/content/copilot/how-tos/copilot-sdk/features/customization-locations.md new file mode 100644 index 000000000000..96604c079ea5 --- /dev/null +++ b/content/copilot/how-tos/copilot-sdk/features/customization-locations.md @@ -0,0 +1,68 @@ +--- +title: Customization discovery locations +shortTitle: Customization Locations +intro: >- + GitHub Copilot can discover versioned skills, agents, instructions, and file + hooks directly from package installations, submodules, and vendored + directories. You don't need to copy them into conventional directories. +versions: + fpt: '*' + ghec: '*' +contentType: how-tos +--- + + + + + + + +## Repository settings + +Declare checkout-relative directories in `.github/copilot/settings.json`: + +```json +{ + "skillDirectories": ["node_modules/@example/team/skills"], + "agentDirectories": ["vendor/team/agents"], + "instructionDirectories": ["vendor/team/instructions"], + "hookDirectories": ["vendor/team/hooks"] +} +``` + +Each list adds discovery locations for its own customization type. Conventional locations continue to work. A key in `.github/copilot/settings.local.json` replaces only that repository list; an empty list removes its repository contribution. Omitted keys inherit the checked-in list. Repository lists don't replace SDK inputs or existing user skill directories. + +Repository paths must be nonempty, literal directories within the declaring checkout. Absolute paths, home or environment-variable expansion, globs, and escapes outside the checkout are rejected. Links and junctions are allowed only when their resolved roots and discovered descendants remain inside the checkout. A missing directory is reported and can be discovered after installation and an explicit customization reload. Copilot doesn't install packages or watch these directories. + +File content from repository-defined discovery locations is read through a validated, opened file handle. Retargeting a link outside the checkout between discovery and invocation doesn't authorize reading its new target. + +Repository locations require trust covering the declaring checkout root and remain subject to each customization type's existing discovery switches, disabled entries, and managed policy. Persisted trust for only a subdirectory doesn't authorize its parent checkout's settings. Listing a location doesn't enable a disabled customization type or grant filesystem access. + +In the CLI, remembered folder trust refreshes repository customizations in the current session, even if model catalog initialization failed. `/skills reload` also rechecks persisted trust and repository discovery settings. + +## SDK inputs + +Session creation, resume, and option updates accept the same four lists. TypeScript and Java use `skillDirectories`, `agentDirectories`, `instructionDirectories`, and `hookDirectories`. Python and Rust use snake case; Go and .NET use Pascal case. + +SDK directories can be outside the checkout. Leading and trailing whitespace is trimmed, and blank entries are ignored. Relative SDK paths resolve once against the request's effective working directory when supplied. Changing the working directory doesn't move retained SDK roots. Supplying a new relative list uses the new effective working directory. An empty list clears only the explicit SDK contribution; omission or `null` leaves it unchanged during an update. + +If adopting a `hookDirectories` replacement fails or is canceled before the new hook service takes ownership, the previous directory option and live hook service remain in place. Overlapping failed updates restore the retained value rather than another failed update's directories. A failed update doesn't roll back a newer accepted update. + +Explicit agent and file-hook directories don't require ambient configuration discovery. `enableFileHooks` controls repository-origin hooks, including conventional `.github/hooks`, but not explicit SDK hook directories or callback `hooks`. Managed restrictions and hook disables still apply. + +Directory inputs are discovery configuration, not general filesystem grants. They don't widen `additionalDirectories`. Existing file-hook sandbox rules remain in effect. + +Loading active local skills adds read-only grants for their contributing SDK skill directories under the runtime's skill-resource policy. Empty, ignored, and disabled skill sources don't add those grants. Repository-defined skill directories don't add them. + +## File formats and precedence + +| Type | Discovered content | +| --- | --- | +| Skills | A direct `SKILL.md` bundle or recursively discovered topmost bundles | +| Agents | Existing Markdown agent definitions | +| Repository instructions | Recursive `*.instructions.md` files, with existing frontmatter and `applyTo` matching | +| Hooks | Existing hook JSON configurations | + +Repository instruction directories don't import package `AGENTS.md` hierarchies or `.claude/rules`. Existing SDK instruction-directory formats remain supported. Each subsystem preserves its existing source precedence. Overlapping hook file references are deduplicated; distinct authored actions aren't collapsed because their commands happen to match. + +File-backed command hooks receive `COPILOT_HOOK_DIR`, the directory containing their configuration file. Use it to locate adjacent scripts and resources. Hook working-directory semantics don't change: they remain anchored to the consuming workspace. `COPILOT_HOOK_DIR` is an execution environment variable, not JSON template expansion; `COPILOT_PLUGIN_ROOT` retains its separate plugin-root behavior. Inline hooks and SDK callbacks don't gain a fabricated configuration directory. diff --git a/content/copilot/how-tos/copilot-sdk/features/fleet-mode.md b/content/copilot/how-tos/copilot-sdk/features/fleet-mode.md index 96be8a3ea9a7..4c633dc1a701 100644 --- a/content/copilot/how-tos/copilot-sdk/features/fleet-mode.md +++ b/content/copilot/how-tos/copilot-sdk/features/fleet-mode.md @@ -201,7 +201,9 @@ This pattern gives every worker a clear owner and lets the parent session reason Fleet mode invokes sub-agents through the runtime's task mechanism. The runtime emits hook activity for sub-agent tool calls: the runtime 1.0.52 changelog notes that `preToolUse`, `postToolUse`, `subagentStart`, and `subagentStop` fire correctly for sub-agent tool calls. -A dedicated SDK hook callback for `subagentStart` or `subagentStop` was not found in the public SDK surface on this branch. SDK consumers can observe sub-agent activity through the generic session event stream, which includes events such as `subagent.started`, `subagent.completed`, `subagent.failed`, `subagent.selected`, and `subagent.deselected`. +All six SDKs expose typed sub-agent lifecycle hooks: Node.js uses `onSubagentStart` and `onSubagentStop`, Python and Rust use `on_subagent_start` and `on_subagent_stop`, Go and .NET use `OnSubagentStart` and `OnSubagentStop`, and Java uses `setOnSubagentStart` and `setOnSubagentStop`. Register them when creating or resuming a session. The start hook can prepend `additionalContext` to the child's first prompt; the stop hook can request another child turn with a block reason or replace the response reported to the parent. + +For passive status updates, subscribe to the generic session event stream. It includes `subagent.started`, `subagent.completed`, `subagent.failed`, `subagent.selected`, and `subagent.deselected`: {% codetabs %} {% codetab typescript %} @@ -270,7 +272,7 @@ Keep plugin-provided sub-agent types narrow and descriptive so the orchestrator * Fleet mode is exposed through generated session RPC bindings and is marked experimental in several SDKs. * The SQL todos pattern is the canonical coordination model in the runtime guidance, but whether it is a stable extensibility contract for SDK consumers is still an open question. -* `subagentStart` and `subagentStop` are runtime hook names; this branch exposes sub-agent lifecycle to SDK consumers through the generic session event stream, not dedicated hook callbacks. +* Sub-agent lifecycle hook inputs identify the parent session but do not yet carry a shared tool-call ID for correlating with `subagent.*` events. * Plugin sub-agent registration is configured at the runtime layer through `--plugin-dir`; no SDK-level plugin registration helper was verified on this branch. * Java native typed bindings for `session.fleet.start` were not found in the Java SDK source on this branch. * Fleet mode does not remove the need for parent-agent review. Parallel workers can produce inconsistent assumptions that the orchestrator must reconcile. diff --git a/content/copilot/how-tos/copilot-sdk/features/hooks.md b/content/copilot/how-tos/copilot-sdk/features/hooks.md index 44d381007a0e..490bbb7500ff 100644 --- a/content/copilot/how-tos/copilot-sdk/features/hooks.md +++ b/content/copilot/how-tos/copilot-sdk/features/hooks.md @@ -37,8 +37,83 @@ A hook is a callback you register once when creating a session. The SDK invokes All hooks are **optional**—register only the ones you need. Returning `null` (or the language equivalent) from any hook tells the SDK to continue with default behavior. +File-based repository hooks and repository hook settings are loaded only after the workspace is trusted. User hooks remain available before workspace trust is granted. + +## Repository hooks from additional directories + +Repository filesystem hooks are separate from the SDK callbacks described in +this guide. To load them from additional directories during local session +creation or cold resume, supply `additionalDirectories`; CLI `--add-dir` also participates by default. +`DISABLE_ADDITIONAL_DIRECTORY_HOOKS=true` restores primary-only scope; +`enableFileHooks` can enable or disable repository hooks through `session.rpc.options.update`; explicit SDK hook directories and callbacks are independent. + +Discovery uses each accepted directory's containing Git root, or the directory +itself when it is outside a Git repository. For example, supplying +`B\packages\core` inside repository `B` admits `B\.github\hooks` and B's repository +hook settings, not just configuration inside `B\packages\core`. + +> [!WARNING] +> Supply only trusted startup directories. This authorizes hook commands +> and hook-provided prompts from all eligible startup directories without +> additional per-directory or per-hook approval. Later file-access grants +> do not authorize hooks. Existing managed policy, hook-disable controls, and +> sandbox rules still apply; they do not make malicious hook content safe. + +Later directory grants do not add hook sources. A cold resume must resupply the +directories; later grants or escape-hatch updates cannot change retained +authorization. + ## Registering hooks +Callback `hooks` remain separate from file-backed `hookDirectories`. For packaged hook configurations and adjacent resources, see [AUTOTITLE](/copilot/how-tos/copilot-sdk/features/customization-locations). + +### Agent-scoped declarative hooks + +Custom agents can declare a `hooks` event map in `.agent.md` frontmatter or in +the SDK's custom-agent configuration. These are declarative command or HTTP +actions, not SDK callback functions. They run only while that agent is selected, +in addition to applicable session, user, repository, plugin, and managed-policy +hooks. Changing or clearing the selected agent changes the scoped hooks for the +next invocation without changing other sessions or concurrent subagents. + +```yaml +--- +name: formatter +description: Formats changed files +hooks: + PostToolUse: + - type: command + command: ./scripts/format-changed-files.sh + windows: ./scripts/format-changed-files.ps1 +--- +Format the files you change. +``` + +Both runtime event names such as `postToolUse` and supported VS Code aliases +such as `PostToolUse` are accepted. `command` supplies the fallback shell command; +`windows`, `linux`, and `osx` override it on the corresponding platform. Hook +commands are relative to the executing session's repository root, unless they +set an explicit working directory. Hook payloads and decisions follow the +runtime's existing hook contracts. + +When a local agent runs as a subagent, its scoped `Stop` (`agentStop`) actions +run at `SubagentStop` instead of ordinary turn stop. An explicit `SubagentStop` +action on that agent still observes its own children. Stop callbacks receive +`stop_hook_active: true` after a prior block. After eight consecutive scoped +Stop continuations, the runtime ignores further scoped blocks and accepts the +child's final response unless a parent lifecycle hook still blocks completion. + +Repository and inherited-agent hooks require workspace admission. Agent +discovery or selection does not itself grant trust. Existing hook-disable +settings, enterprise restrictions, sandbox permissions, and timeouts also apply +to scoped actions. SDK-provided agents cannot claim managed-plugin privileges. +Remote or organization-hosted agent definitions do not execute scoped hooks, +and scoped Stop dispatch is supported for local runtime subagents, not external +AHP participants. + +Unsupported event names are reported before event selection. Action preparation +errors affect only their declared event; shared and managed-policy hooks still run. + Pass a `hooks` object when you create (or resume) a session. Every example below follows this pattern. {% codetabs %} diff --git a/content/copilot/how-tos/copilot-sdk/features/index.md b/content/copilot/how-tos/copilot-sdk/features/index.md index bcf8586f08c2..3989d59bba47 100644 --- a/content/copilot/how-tos/copilot-sdk/features/index.md +++ b/content/copilot/how-tos/copilot-sdk/features/index.md @@ -12,11 +12,13 @@ redirect_from: contentType: how-tos children: - /agent-loop + - /changing-tools - /citations - /client-info - /cloud-sessions - /context-management - /custom-agents + - /customization-locations - /fleet-mode - /hooks - /image-input diff --git a/content/copilot/how-tos/copilot-sdk/features/mcp.md b/content/copilot/how-tos/copilot-sdk/features/mcp.md index 9569df0f34d1..db216d2f0afe 100644 --- a/content/copilot/how-tos/copilot-sdk/features/mcp.md +++ b/content/copilot/how-tos/copilot-sdk/features/mcp.md @@ -41,6 +41,44 @@ The SDK supports two types of MCP servers: ## Configuration +### Inspect configuration and lifecycle + +`session.rpc.mcp.listConfigured()` is a parameterless, passive inventory read. +It does not start or restart servers, authenticate, apply sandbox policy, or +wait for server startup. Configuration discovery and policy filtering determine +which servers appear and their `enabled` values. + +This experimental RPC requires an aligned SDK/runtime release. Its earlier +prerelease response allowed `live` to be omitted; SDKs using the required +lifecycle contract do not support that older response shape. When supplying +an external runtime, update it alongside the SDK. The overall protocol version +is unchanged and does not identify this experimental contract revision. + +Every entry includes `live.status`. A configured server with no materialized +runtime server reports the existing `not_configured` status, even when +`enabled` is `true`. A runtime server that was explicitly stopped reports +`stopped`. Materialized servers report their actual lifecycle, including +`pending`, `connected`, `needs-auth`, and `failed`; failures include `live.error` +when available. + +Configuration and lifecycle describe different facts. If a server's URL, +authentication, or configuration source changes while an old connection remains +alive, inventory still reports that connection's actual lifecycle. A +`connected` status does not mean the latest configuration has been applied. +The runtime owns this observation; clients must not substitute cached state +based only on the server name. + +Inventory retries concurrent graph replacement a bounded number of times and +returns an error if it cannot obtain a consistent snapshot. It never omits +`live` to hide a graph race. + +A name-only `startServer` or `restartServer` resolves and initializes only the +requested server after a cold session resume without first calling `list`. +This includes user and plugin configurations and the built-in GitHub server +configured through `githubMcpToolConfig`. It does not start other configured +servers or override disabled state, authentication requirements, sandbox policy, +or configuration-source precedence. + ### Node.js / TypeScript ```typescript @@ -198,6 +236,57 @@ On session creation and a **cold** resume, disabled servers are not started and the runtime does not initiate their authentication. A resident resume cannot undo a server that the runtime has already spawned. Names are matched exactly. +## Listing and retrieving prompts + +The experimental generated MCP namespace exposes the wire JSON-RPC methods +`session.mcp.prompts.list` and `session.mcp.prompts.get`. Use the language-specific +SDK accessors below to call them. These methods target one connected server in the +current session; they do not combine results from multiple servers. + +| SDK | List prompts | Get a prompt | +| --- | --- | --- | +| Node.js | `session.rpc.mcp.prompts.list(...)` | `session.rpc.mcp.prompts.get(...)` | +| Python | `session.rpc.mcp.prompts.list(...)` | `session.rpc.mcp.prompts.get(...)` | +| Go | `session.RPC.MCP.Prompts().List(...)` | `session.RPC.MCP.Prompts().Get(...)` | +| .NET | `session.Rpc.Mcp.Prompts.ListAsync(...)` | `session.Rpc.Mcp.Prompts.GetAsync(...)` | +| Java | `session.getRpc().mcp.prompts.list(...)` | `session.getRpc().mcp.prompts.get(...)` | +| Rust | `session.rpc().mcp().prompts().list(...)` | `session.rpc().mcp().prompts().get(...)` | + +Listing requires `serverName` and accepts an optional opaque `cursor`. Each +call returns one page of typed prompt definitions and an optional `nextCursor`. +Pass that cursor in another list call to request the next page. Definitions +include names, optional titles and descriptions, and argument definitions. +An omitted argument `required` flag remains distinct from `false`. + +Getting a prompt requires `serverName` and `promptName`, with an optional +`arguments` dictionary whose values are strings. Omitting `arguments` leaves the +MCP request's arguments absent; passing `{}` sends an explicitly empty dictionary. +The runtime preserves this distinction for the server. The result contains an optional +description and ordered messages with typed roles and opaque JSON `content`. +Inspect each content block's `type` before interpreting it; content is not +flattened into text. Nested resources, annotations, metadata, unfamiliar content +types, and additional content fields retain their JSON structure. Server +extensions on typed descriptors, messages, and result envelopes are available under +`additionalProperties`. A server extension itself named `additionalProperties` +is preserved as an entry inside that map, not merged into it. +The protocol's top-level `resultType: "complete"` discriminator is consumed by +the runtime; it is not returned as an SDK field or a server extension. +Requests fail explicitly if the runtime cannot preserve the raw response, +including stdio response frames exceeding the 1 MiB capture limit. + +The returned prompt messages are not automatically sent to the model or used to +execute tools or fetch referenced resources. Your application decides how to +use the result. +While generating that result, an MCP server can request sampling or elicitation +through multi round-trip continuations. These requests use the same configured +host responders as other MCP operations; prompt retrieval does not grant +additional permission or bypass the host's decision. A missing responder or +responder error fails the request, and an elicitation decline is returned to the +server unchanged. State-only continuations do not invoke either responder. + +After an existing `mcp.prompts.list_changed` session event, list the named +server's prompts again to refresh your application's view. + ## Tool configuration You can control which tools are available to an MCP server using the `tools` field. @@ -304,9 +393,242 @@ directories for different applications. | `type` | `"http"` or `"sse"` | Yes | Server type | | `url` | `string` | Yes | Server URL | | `headers` | `object` | No | HTTP headers (e.g., for auth) | +| `oauthClientId` | `string` | No | Non-empty client ID for a statically configured OAuth client | +| `oauthScopes` | `string[]` | No | Non-empty array of valid RFC 6749 scope tokens. Requires `oauthClientId`; used when the server challenge omits scope or provides an empty scope, before protected-resource metadata fallback | +| `oauthPublicClient` | `boolean` | No | Whether the configured OAuth client is public and does not require a client secret | +| `oauthGrantType` | `string` | No | OAuth grant type for the configured client, such as `client_credentials` | | `tools` | `string[]` | No | Tools to enable | | `timeout` | `number` | No | Timeout in milliseconds | +## OAuth ownership for remote servers + +Choose one OAuth ownership model for each authentication attempt: + +1. **Runtime-managed loopback**: Call `session.mcp.oauth.login` without a + redirect URI. The runtime opens a local callback listener and owns discovery, + PKCE, state validation, token exchange, persistence, refresh, and reconnect. +1. **Runtime-managed hosted callback**: Call `session.mcp.oauth.login` with a + trusted public HTTPS redirect URI. Your host receives the callback, but the + runtime continues to own the OAuth protocol and credentials. +1. **Host-managed OAuth**: Use the SDK's MCP authentication request handler and + return a host-acquired access token. Your host owns authorization and refresh. + +Do not register a host-managed OAuth handler for an authentication attempt that +uses a runtime-managed hosted callback. Wait until the remote server enters the +`needs-auth` state, then call the generated `session.rpc.mcp.oauth.login` method +directly with the server name and callback URI: + +```jsonc +{ + "serverName": "remote-mcp", + "redirectUri": "https://agent.example.com/oauth/callback" +} +``` + +The selected static, CIMD, or dynamically registered OAuth client must advertise +that exact URI. The URI must use HTTPS and must not contain a query or fragment. +When browser interaction is required, the result contains `authorizationUrl` +and `authorizationId`. Open `authorizationUrl` in the user's browser and retain +`authorizationId` with the target session. + +After the authorization server redirects to your endpoint, call the generated +`session.rpc.mcp.oauth.complete` method: + +```jsonc +{ + "authorizationId": "", + "callbackUrl": "https://agent.example.com/oauth/callback?code=...&state=..." +} +``` + +Construct `callbackUrl` from the configured public redirect origin and path plus +the callback's original query. Do not pass an internal service URL or trust +client-supplied `Forwarded` or `X-Forwarded-*` headers. The runtime validates the +origin, port, path, state, and OAuth response before accepting delivery. Token +exchange, persistence, and MCP reconnect then continue asynchronously. + +### Handing a rejected access token back to the runtime + +For a host OAuth request with `reason: "refresh"` and +`runtimeRefreshSupported: true`, your handler can return +`{ "kind": "runtimeRefresh" }` instead of acquiring a replacement access token. +The runtime performs noninteractive renewal using its existing saved grant +inside the original authorization operation. It does not start a new consent +flow or call the independent `authenticationStateChanged` refresh RPC. +The replacement retains its saved-grant provenance, so later scoped stored-token +adoption remains available. A token supplied directly by a host is not marked as +a stored token and cannot be overwritten by that adoption path. + +Only return this response when the pending request advertises support. A missing +capability is false and retains the legacy token/cancellation contract. If a +handler returns the handoff for a request without the capability, the SDK sends +`cancelled` instead, so the pending request does not remain open. A supplied +token takes precedence over the handoff, and a cancellation stays a cancellation. + +| SDK | Capability | Handoff result | +| --- | --- | --- | +| Node.js | `request.runtimeRefreshSupported` | `{ kind: "runtimeRefresh" }` | +| Python | `request["runtimeRefreshSupported"]` | `{"kind": "runtimeRefresh"}` | +| Go | `MCPAuthRuntimeRefreshHandler` capability argument | `MCPAuthResultRuntimeRefresh()` | +| .NET | `McpAuthContext.RuntimeRefreshSupported` | `McpAuthResult.RefreshInRuntime()` | +| Java | `McpAuthHandler.handleWithRuntimeRefresh` capability argument | `McpAuthResponse.runtimeRefresh()` | +| Rust | `McpAuthHandler::handle_with_runtime_refresh` | `McpAuthResponse::RuntimeRefresh` | + +Response acceptance acknowledges the handoff, not refresh, reconnect, or tool +completion. For an accepted handoff, `mcp.oauth_completed` is emitted once, when +the original authorization reservation reaches its terminal outcome. Its +`outcome` uses only the existing values: `token` when recovery succeeded, and +`cancelled` when it was cancelled or failed. The persisted +`mcp.oauth_recovery_completed` event carries the same `requestId` and +`serverName` and distinguishes the terminal outcome, including reconnect: +`succeeded`, `cancelled`, or `failed`. SDK versions that predate this event +receive it as an unknown event type. + +Until the runtime accepts a `runtimeRefresh` response, existing replies keep +their earlier meaning. A host `{ kind: "cancelled" }` reply declines the +request; for a `refresh` request, the runtime can then request reauthorization. +If no host replies, the runtime falls back to its ordinary local OAuth handling, +including cached credentials, and emits no `mcp.oauth_recovery_completed` event. +This change does not reclassify a host `cancelled` reply as Stop. The original +invocation is preserved only through an accepted `runtimeRefresh` handoff. + +After acceptance, the original +invocation can retry after credential installation and reconnect pass the +existing session, configuration, policy, and permission fences. Stopping the +main turn or ending the session cancels the original authorization reservation; +the stopped invocation cannot later retry or publish a result. An already-issued +rotating-grant exchange can finish persisting its replacement after cancellation, +but cannot install stale credentials, reconnect, or resume the canceled +invocation. + +An unavailable saved grant or failed refresh terminates the original invocation +without requesting interactive reauthorization. A failed reconnect also +terminates the invocation; successfully obtaining a token is not a successful +tool result. + +Concurrent invocations join the exact in-flight authorization attempt and receive +its terminal result. A connected server, a retained access token, or a later +successful attempt is not evidence that their attempt succeeded. Each surviving +invocation still rechecks its own catalog and tool permission before retrying; +joined failure does not start another interactive authorization. + +OAuth HTTP deadlines apply to individual requests, not the complete handoff or +grant-storage settlement. Cancellation can return before an issued exchange +settles, and session disposal waits for tracked settlement workers. There is no +overall settlement deadline, so hosts must not treat cancellation, handoff +acceptance, or an HTTP timeout as proof that all storage work has finished. + +## Session-scoped MCP diagnostics + +Set `diagnostics: { sources: { mcp: { level: "debug" } } }` when you create or +resume a session to opt in to MCP diagnostics. Every source defaults to `off`. +The enabled levels are `error`, `warning`, `info`, `debug`, and `trace`. This +setting is separate from the client's process-level `logLevel`. + +> [!WARNING] +> Diagnostic entries can contain user-provided or server-provided content at +> every enabled level: `warning` and above can include server stderr, while +> `debug` and `trace` can additionally include MCP payloads, tool arguments, +> and paths. Treat every entry as sensitive. Do not automatically upload entries +> as telemetry or export them without deliberate host action. + +The generated `session.rpc.diagnostics` API lets hosts configure and read +session-scoped, bounded in-memory diagnostics. MCP is the first and currently +only supported source. Explicitly select it with `sources: ["mcp"]` when reading; +reading does not enable capture. Use one stoppable read loop +per output surface and retain its cursor. A read loop owns its cursor and +presentation; the runtime owns capture, filtering, retention, cursor expiry, +and wakeups. + +```typescript +import { approveAll, CopilotClient } from "@github/copilot-sdk"; + +const client = new CopilotClient(); +const stop = new AbortController(); +const appendLine = (line: string) => console.log(line); + +await client.start(); +const session = await client.createSession({ + onPermissionRequest: approveAll, + diagnostics: { sources: { mcp: { level: "debug" } } }, +}); + +try { + let cursor: string | undefined; + while (!stop.signal.aborted) { + const page = await session.rpc.diagnostics.read({ + sources: ["mcp"], + cursor, + max: 100, + waitMs: 30_000, + }); + if (page.cursorStatus === "expired") { + appendLine("MCP diagnostic entries were dropped."); + } + for (const entry of page.entries) { + const detail = entry.details.data ? ` ${entry.details.data}` : ""; + appendLine( + `${entry.timestamp} [${entry.level}] [${entry.source}:${entry.details.serverName}] ${entry.message}${detail}`, + ); + } + cursor = page.cursor; + } +} finally { + await session.disconnect(); + await client.stop(); +} +``` + +The abort signal stops the loop after its current bounded read completes; it is +not passed to the RPC call. Disabling capture wakes an outstanding read. + +MCP startup is lazy: enabling or reading diagnostics does not start a server. +Configure `mcpServers` on session creation, then either use the session normally +or call `session.rpc.mcp.startServer({ serverName })` for an installed server +to troubleshoot initialization without making a model request. + +Each record has `source: "mcp"`, timestamp, severity, message, and optional +`agentId` for non-root agents. Its typed `details` contains the lifecycle, +protocol, HTTP, or stderr category, server name, a fresh `connectionId` for each +connection attempt, and optional protocol direction and diagnostic data. +MCP retention is capped at 1,024 records and 4 MiB per session. Individual encoded +records are capped at 16 KiB and carry `truncated` when shortened. Reads default +to 100 records, accept a maximum of 500, and do not consume other readers' data. +Use `droppedCount`, when present on an expired cursor, to show the known loss. +Every successful read returns a cursor and cursor status, including empty +reads. Keep the returned cursor even when no records arrive. A cursor belongs +to its source selection; start without a cursor when changing that selection. +Missing, empty, duplicate, or unsupported sources are rejected, as are malformed +cursors and invalid numeric bounds. Neither records nor capture settings are +saved in session history. + +Call `session.rpc.diagnostics.configure({ sources: { mcp: { level: "off" } } })` +to disable MCP capture and clear its retained buffer. Configuration updates only +the explicitly named sources and returns the effective source levels. Empty or +unknown source configuration is rejected; adding another supported source in +the future will not implicitly opt an existing caller into it. + +Stop the host's read loop too; reads while logging +is off return immediately rather than waiting. An outstanding long poll wakes +when logging is disabled. Omitting `diagnostics` during a resident resume +preserves its current configuration; a cold-loaded session requires a new opt-in. +Enabling diagnostics from the initial `off` state can begin capture for an +already-live MCP connection. After diagnostics have been disabled with +`configure({ sources: { mcp: { level: "off" } } })`, re-enabling can resume capture for the same +live connection and its existing connection ID. Records that were already in +flight when diagnostics were disabled are discarded. + +The runtime removes configured and structurally recognized credentials from HTTP +diagnostics before entries reach this API, but this is not a guarantee that +arbitrary server-provided secrets are absent. It does not expose request or +response header maps. Only selected metadata, such as `Content-Type`, `Accept`, +and `MCP-Protocol-Version`, is eligible for capture, but its values are still +untrusted and can contain sensitive content. Authentication headers, cookies, +session IDs, query values, URL userinfo, and fragments are omitted. URL paths +are retained for troubleshooting, so do not place credentials in path segments. +Protocol payloads and stderr can also contain user content or arbitrary +server-provided secrets. + ## Troubleshooting ### Tools not showing up or not being invoked diff --git a/content/copilot/how-tos/copilot-sdk/features/plugin-directories.md b/content/copilot/how-tos/copilot-sdk/features/plugin-directories.md index d7a9b68227b5..d5cbfc175c73 100644 --- a/content/copilot/how-tos/copilot-sdk/features/plugin-directories.md +++ b/content/copilot/how-tos/copilot-sdk/features/plugin-directories.md @@ -268,6 +268,34 @@ for (const plugin of plugins.plugins) { Plugins loaded via `--plugin-dir` appear in this list with their cache path set to the directory you provided. Marketplace installs are tagged with their registry source. +## Retrying plugins required by managed settings + +When managed settings require plugins through `enabledPlugins`, the runtime installs or updates them before chat messages. If that fails, the session emits a `session.warning` with `warningType: "managed_plugins"` and waits an hour, or until the managed settings change, before trying again. The experimental `session.rpc.plugins.retryManaged()` tries again right away, for example from a Retry action next to that warning. + +Call it with no arguments to retry every plugin the managed settings require, or pass `plugin@marketplace` specs in `plugins` to retry only those. The result has one entry per plugin with a `status`: `installed`, `updated`, `already_present`, `failed`, `deferred`, or `not_required`. `failed` and `deferred` entries include an `error`. The session also emits the usual `session.info` progress and `session.warning` failure events. + +
+Node.js / TypeScript + +```typescript +// Retry every plugin the managed settings require, for example from a Retry action. +const result = await session.rpc.plugins.retryManaged(); +for (const entry of result.plugins) { + console.log(`${entry.spec}: ${entry.status}${entry.error ? ` (${entry.error})` : ""}`); +} + +// Or retry only the required plugins that are still missing. +const { plugins } = await session.rpc.plugins.list(); +const missing = plugins + .filter((plugin) => plugin.managedDesiredEnabled === true && plugin.installed === false) + .map((plugin) => `${plugin.name}@${plugin.marketplace}`); +await session.rpc.plugins.retryManaged({ plugins: missing }); +``` + +
+ +Runtimes that do not prepare managed plugins for the session reject the call. + ## Troubleshooting * **"no plugin.json or SKILL.md found in <dir>"** — the directory exists but doesn't qualify as a plugin. Add a `plugin.json` manifest at the root (or under `.github/`), or include a top-level `SKILL.md`. diff --git a/content/copilot/how-tos/copilot-sdk/features/session-limits.md b/content/copilot/how-tos/copilot-sdk/features/session-limits.md index 4afbb3e36253..b59fc727a588 100644 --- a/content/copilot/how-tos/copilot-sdk/features/session-limits.md +++ b/content/copilot/how-tos/copilot-sdk/features/session-limits.md @@ -1,6 +1,6 @@ --- title: Session limits -shortTitle: Session limits +shortTitle: Session Limits intro: >- Session limits let an application set an AI Credits budget for a Copilot session. Use `sessionLimits` when creating or resuming a session to set a soft diff --git a/content/copilot/how-tos/copilot-sdk/features/session-persistence.md b/content/copilot/how-tos/copilot-sdk/features/session-persistence.md index 013b58ac3202..35d7a6b334da 100644 --- a/content/copilot/how-tos/copilot-sdk/features/session-persistence.md +++ b/content/copilot/how-tos/copilot-sdk/features/session-persistence.md @@ -112,6 +112,58 @@ await session.SendAndWaitAsync(new MessageOptions { Prompt = "Analyze my codebas Later—minutes, hours, or even days—you can resume the session from where you left off. +### Remembered permissions with a session filesystem + +When you configure a custom `sessionFs` provider, runtime-owned durable permission choices use provider storage rather than the runtime host's configuration. The files live under the provider's `sessionStatePath` and retain their existing formats: + +* `permissions.json`: A top-level `locations` map retaining the existing `tool_approvals` and `allowed_directories` fields +* `settings.json`: Top-level `allowedUrls` for `approve-permanently` URL approvals and `sandbox` for saved sandbox preferences +* `config.json`: Top-level `trustedFolders` for remembered folder trust and `sandboxOnboardingShown` for the first-enable sandbox message + +Location keys are normalized provider directory paths, such as `/workspace`, without an added prefix. The runtime resolves them lexically using provider path conventions, without inspecting the host filesystem or discovering host Git repositories. An approval saved for `/workspace` is not automatically selected when the working directory is `/workspace/sub`. + +Provider-backed folder trust also uses lexical paths: a trusted folder covers itself and its descendants, not linked Git worktrees at other paths. Trust those worktree paths explicitly. + +There is no additional session-ID partition inside these files. Sessions that access the same backing files share remembered choices. Use separate provider namespaces when sessions or tenants must not share grants. With a namespace per session, permanent choices survive that session's resumes but do not automatically carry over to new sessions. To restore a session's persisted choices on resume, reconnect it to the same namespace. + +Consent updates are serialized per provider endpoint and file within one runtime process. Separate endpoints do not block each other. If different provider endpoints or runtime processes share backing files, the application must coordinate concurrent updates; the runtime cannot infer that those files are shared. + +These operations never import, update, or fall back to the host's `permissions-config.json`, `settings.json`, or `config.json`, including when provider reads or writes fail. Existing-format files supplied through the provider are read directly. + +As with host configuration, URL approvals are read only from `settings.json`. A legacy `allowedUrls` field in `config.json` is ignored; `config.json` remains the global-state file for remembered folder trust and the sandbox onboarding marker. + +Storage routing does not change permission lifetimes, URL denial precedence, or session policy behavior. Approve-once and approve-for-session do not become permanent grants. + +Saved sandbox preferences are restored on create and resume. Later explicit SDK `options.update` calls can change the live sandbox configuration without rewriting the saved preference. + +Without `sessionFs`, SDK applications in both `empty` and `copilot-cli` modes retain existing host-backed persistence. This includes the existing behavior where permanent URL consent saves host settings and grants access in the live session, but a fresh SDK session does not automatically load the host URL allowlist. + +See [AUTOTITLE](/copilot/how-tos/copilot-sdk/setup/multi-tenancy#sessionfs) for provider isolation responsibilities. + +### Transcript recovery + +The resume option `allowTranscriptRecovery` controls recovery when the runtime loads a +transcript from storage. It defaults to `true` in all modes. With `false`, a load that +would discard damaged records or move `session.start` fails with JSON-RPC error `-32075` +without rewriting the transcript. An intact final record without a newline is accepted. + +Records skipped for forward compatibility still count when checking persisted order. +If such records precede `session.start`, recovery must move the start record ahead of +them. A transcript containing only skipped records is not empty and cannot be recovered +without a valid `session.start`. + +When recovery is allowed, the returned session exposes `transcriptRecovery`, including +the invalid line numbers, whether `session.start` moved, and a planned byte-exact backup +path. Loading alone does not write that backup; the next durable append performs the +repair and backup. + +> [!NOTE] +> This option applies to a new transcript load, not to the lifetime of a session. +> Reconnecting to a session already resident in the runtime reuses its live history +> without revalidating storage or rejecting recovery authorized by an earlier resume. +> The recovery report describes the load that performed recovery, not every subsequent +> reconnect. + ![Diagram: Flowchart showing the described process.](/assets/images/help/copilot/copilot-sdk/features-session-persistence-diagram-1.png) ### TypeScript @@ -184,7 +236,19 @@ When resuming a session, you can optionally reconfigure many settings. This is u With `model: "auto"`, the optional `capi.autoTier` setting selects an Auto routing preference: `efficiency`, `balance`, `intelligence`, or `fast`. In Python, use `capi={"auto_tier": "balance"}`. This setting applies to V2 Auto routing; V1 Auto requests are unchanged. -`fast` is an integrator-only latency preset, not a first-party GitHub Copilot product preference. The SDK does not decide Fast eligibility, inspect client identity, choose it as a default, or fall back to another tier when a runtime does not support it—an older runtime returns its native error unchanged. +When the runtime's default-off `DYNAMIC_AUTO_TIERS` feature is enabled, the session's `model.list` RPC also returns optional `auto` metadata from the provider's `/meta` endpoint. Use the enabled descriptors with `type: "auto"` to discover additional supported tier identifiers, their display names, descriptions, and ordering. The existing selection methods and JSON fields accept these identifiers. A discovery failure does not fall back to `/models`, and a successful response without `auto` metadata provides no selectable Auto tiers. + +To populate a model picker before any session exists, enable `DYNAMIC_AUTO_TIERS` in the runtime's environment (for example, the `env` client option). The server-level `models.list` RPC (`client.rpc.models.list()` in Node.js) then returns the same `auto` metadata from the same `/meta` response, with the same no-fallback behavior. The server-level listing reads the flag the runtime resolved at startup, not per-session feature flags or experiment assignments, so set the flag where you launch the runtime. With the flag disabled, `models.list` is unchanged and omits `auto`. + +Treat tier identifiers as extensible values, not a closed enumeration. Existing named values remain available; for additional identifiers, use strings in Node.js and Python, `AutoTier("premium-v2")` in Go, `new AutoTier("premium-v2")` in .NET, `AutoTier::Custom` in Rust, or `AutoTier.fromValue` in Java. The provider's `defaultTier` describes its default routing without creating a committed SDK preference. + +If a persisted tier is removed or disabled, resume preserves the explicit preference rather than replacing it with the provider's default. Select an available replacement before activating Auto routing. + +The CLI also preserves settings-derived preferences during startup, even when a tier is unavailable or dynamic discovery is disabled. You can then select a replacement in the model picker. Explicit SDK create and resume preferences are still validated before the operation succeeds, and Auto activation always validates availability against the resolved discovery mode. + +When a create or resume request includes `expAssignments`, the runtime installs those assignments before validating an explicit Auto preference. Validation, discovery, and activation use the same resolved feature decision. + +`fast` is an integrator-only latency preset, not a first-party GitHub Copilot product preference. Fast is not validated against the `/meta` tier catalog when configuring a session. The SDK does not decide Fast eligibility, inspect client identity, choose it as a default, or fall back to another tier when a runtime does not support it—an older runtime returns its native error unchanged. The runtime persists the selected tier, so applications do not need to resend it on every resume: @@ -210,6 +274,8 @@ if (result.status === "pending") { The runtime does not apply the preference immediately. It records the request and commits it only when a later user turn using the `auto` model successfully obtains a usable model from the provider. A `pending` status therefore confirms that the request was accepted, not that it took effect. Only the most recent request survives: a new request replaces any earlier one that no turn has claimed yet. +If an older request is still validating when a newer preference request or reset arrives, the older call fails with a superseded-request error instead of replacing the newer intent. + Watch for the outcome through these events: * `session.model_change` when the preference commits. diff --git a/content/copilot/how-tos/copilot-sdk/features/skills.md b/content/copilot/how-tos/copilot-sdk/features/skills.md index cf1bc42374aa..d4a0055b19e3 100644 --- a/content/copilot/how-tos/copilot-sdk/features/skills.md +++ b/content/copilot/how-tos/copilot-sdk/features/skills.md @@ -288,6 +288,399 @@ The frontmatter fields: The markdown body contains the instructions that are injected into the session context when the skill is loaded. +## Skill providers (experimental) + +> [!NOTE] +> Skill providers are experimental. The API can change in future SDK releases. + +A skill provider serves skills from your application's own storage, such as a database in a multi-tenant service, instead of `SKILL.md` files on disk. Provider skills join the session's skill catalog alongside file-based skills. The model loads them on demand through the `skill` tool, and users can invoke them like any other skill. + +A provider implements two operations: + +* **List skills**: returns catalog metadata for every skill: a `name`, a `description`, and optionally `userInvocable`, `disableModelInvocation`, and `argumentHint`. The runtime calls it when it loads the session's skills. +* **Read skill**: returns the markdown for one skill, or a not-found result if the skill no longer exists. The runtime calls it only when the skill is loaded. + +Pass the provider when you create or resume a session: + +{% codetabs %} +{% codetab typescript %} + +```typescript +import { approveAll, CopilotClient, type SkillProvider } from "@github/copilot-sdk"; + +const releaseSkills = new Map([ + [ + "release-notes", + { + description: "Writes release notes in the team's format.", + markdown: "Group changes by feature area and link each pull request.", + }, + ], +]); + +const skillProvider: SkillProvider = { + listSkills: () => + [...releaseSkills].map(([name, skill]) => ({ name, description: skill.description })), + readSkill: (name) => releaseSkills.get(name)?.markdown ?? null, +}; + +const client = new CopilotClient(); +const session = await client.createSession({ + onPermissionRequest: approveAll, + skillProvider, +}); +``` + +{% endcodetab %} +{% codetab python %} + +```python +from copilot import CopilotClient, SkillProviderDescriptor +from copilot.session import PermissionHandler + +RELEASE_SKILLS = { + "release-notes": ( + "Writes release notes in the team's format.", + "Group changes by feature area and link each pull request.", + ), +} + + +class ReleaseSkills: + async def list_skills(self) -> list[SkillProviderDescriptor]: + return [ + SkillProviderDescriptor(name=name, description=description) + for name, (description, _) in RELEASE_SKILLS.items() + ] + + async def read_skill(self, name: str) -> str | None: + skill = RELEASE_SKILLS.get(name) + return skill[1] if skill else None + + +async def main(): + client = CopilotClient() + await client.start() + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + skill_provider=ReleaseSkills(), + ) +``` + +{% endcodetab %} +{% codetab go %} + +```golang +package main + +import ( + "context" + "fmt" + "log" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/rpc" +) + +type releaseSkill struct { + description string + markdown string +} + +type releaseSkills map[string]releaseSkill + +func (s releaseSkills) ListSkills(ctx context.Context) ([]rpc.SkillProviderDescriptor, error) { + descriptors := make([]rpc.SkillProviderDescriptor, 0, len(s)) + for name, skill := range s { + descriptors = append(descriptors, rpc.SkillProviderDescriptor{Name: name, Description: skill.description}) + } + return descriptors, nil +} + +func (s releaseSkills) ReadSkill(ctx context.Context, name string) (string, error) { + skill, ok := s[name] + if !ok { + return "", fmt.Errorf("%w: %s", copilot.ErrSkillNotFound, name) + } + return skill.markdown, nil +} + +func main() { + ctx := context.Background() + client := copilot.NewClient(nil) + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + SkillProvider: releaseSkills{ + "release-notes": { + description: "Writes release notes in the team's format.", + markdown: "Group changes by feature area and link each pull request.", + }, + }, + }) + if err != nil { + log.Fatal(err) + } + _ = session +} +``` + +{% endcodetab %} +{% codetab dotnet %} + +```csharp +#pragma warning disable GHCP001 // Skill providers are experimental. +using GitHub.Copilot; +using GitHub.Copilot.Rpc; + +public sealed class ReleaseSkills : ISkillProvider +{ + private readonly Dictionary _skills = new() + { + ["release-notes"] = ( + "Writes release notes in the team's format.", + "Group changes by feature area and link each pull request."), + }; + + public Task> ListSkillsAsync(CancellationToken cancellationToken) => + Task.FromResult>( + _skills.Select(skill => new SkillProviderDescriptor + { + Name = skill.Key, + Description = skill.Value.Description, + }).ToList()); + + public Task ReadSkillAsync(string name, CancellationToken cancellationToken) => + Task.FromResult(_skills.TryGetValue(name, out var skill) ? skill.Markdown : null); +} + +public static class SkillProviderExample +{ + public static async Task RunAsync(CopilotClient client) + { + await using var session = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + SkillProvider = new ReleaseSkills(), + }); + } +} +``` + +The .NET skill provider types raise the `GHCP001` experimental diagnostic. Suppress it with `#pragma warning disable GHCP001` or a project-level `GHCP001`. + +{% endcodetab %} +{% codetab java %} + +```java +import com.github.copilot.AllowCopilotExperimental; +import com.github.copilot.CopilotClient; +import com.github.copilot.CopilotSession; +import com.github.copilot.SkillProvider; +import com.github.copilot.SkillProviderDescriptor; +import com.github.copilot.rpc.PermissionHandler; +import com.github.copilot.rpc.SessionConfig; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +@AllowCopilotExperimental +class ReleaseSkills implements SkillProvider { + private final Map markdown = Map.of( + "release-notes", "Group changes by feature area and link each pull request."); + + @Override + public CompletableFuture> listSkills() { + return CompletableFuture.completedFuture(List.of(new SkillProviderDescriptor( + "release-notes", "Writes release notes in the team's format.", null, null, null))); + } + + @Override + public CompletableFuture readSkill(String name) { + return CompletableFuture.completedFuture(markdown.get(name)); + } +} + +@AllowCopilotExperimental +class SkillProviderExample { + static CopilotSession createSession(CopilotClient client) throws Exception { + return client.createSession(new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setSkillProvider(new ReleaseSkills())) + .get(); + } +} +``` + +The Java skill provider API is `@CopilotExperimental`, so the consuming class or method must opt in with `@AllowCopilotExperimental` (or compile with `-Acopilot.experimental.allowed=true`). See [Using experimental APIs](https://github.com/github/copilot-sdk/tree/main/java/README.md#using-experimental-apis). + +{% endcodetab %} +{% codetab rust %} + + + +```rust +use std::sync::Arc; + +use async_trait::async_trait; +use github_copilot_sdk::{Error, SessionConfig, SkillProvider, SkillProviderDescriptor}; + +struct ReleaseSkills; + +#[async_trait] +impl SkillProvider for ReleaseSkills { + async fn list_skills(&self) -> Result, Error> { + Ok(vec![SkillProviderDescriptor { + name: "release-notes".into(), + description: "Writes release notes in the team's format.".into(), + ..Default::default() + }]) + } + + async fn read_skill(&self, name: &str) -> Result, Error> { + Ok((name == "release-notes") + .then(|| "Group changes by feature area and link each pull request.".to_string())) + } +} + +let session = client + .create_session( + SessionConfig::default() + .approve_all_permissions() + .with_skill_provider(Arc::new(ReleaseSkills)), + ) + .await?; +``` + +{% endcodetab %} +{% endcodetabs %} + +### Provider skill content + +Read skill returns the skill's `SKILL.md` text. YAML frontmatter is optional: + +* Without frontmatter, the whole text is the skill body, and all metadata comes from the listed skill. +* With frontmatter, an omitted field inherits the listed value. A field that you include must match the listed value, or the skill fails to load. +* `allowed-tools` is read only from frontmatter. +* If the first line of the text is `---`, the runtime parses it as frontmatter, so don't start a body-only skill with a Markdown thematic break. + +By default, provider skills are text-only: they have no base directory, so they can't reference bundled scripts, templates, or other files. To give a provider skill such files, see [Supporting files](#supporting-files-experimental). `skills.list` reports provider skills with the source `sdk` and an empty `path`, with or without supporting files. + +### Supporting files (experimental) + +A provider skill can come with supporting files, such as scripts, references, templates, and assets, the same way a file-based skill's folder can. Your application keeps each skill's files in a folder it controls and sets `directory` on the listed skill to that folder's absolute path. + +When the model activates the skill, the runtime lists the folder the same way it lists a file-based skill's folder. The model gets the same "Base directory for this skill" line and related-files list that a file-based skill gets, in place of the text-only notice, and reads the files with its ordinary file tools. The runtime never reads, copies, or writes the files itself. + +```typescript +import { approveAll, CopilotClient, type SkillProvider } from "@github/copilot-sdk"; + +const skillProvider: SkillProvider = { + listSkills: () => [ + { + name: "release-notes", + description: "Writes release notes in the team's format.", + directory: "/srv/skills/release-notes", + }, + ], + readSkill: (name) => + name === "release-notes" + ? "Run `python3 scripts/changelog.py` and fill in `templates/notes.md`." + : null, +}; + +const client = new CopilotClient(); +const session = await client.createSession({ + onPermissionRequest: approveAll, + skillProvider, +}); +``` + +Each SDK sets the folder on its listed-skill type: + +| SDK | Field | +|---|---| +| Node.js | `directory: "/srv/skills/release-notes"` | +| Python | `directory="/srv/skills/release-notes"` | +| Go | `Directory: copilot.String("/srv/skills/release-notes")` | +| .NET | `Directory = "/srv/skills/release-notes"` | +| Java | The sixth `SkillProviderDescriptor` constructor argument | +| Rust | `directory: Some("/srv/skills/release-notes".into())` | + +#### Where the folder lives + +Put all of a skill's files under its `directory`, one folder per skill. The folder must be reachable where the runtime runs: + +* **Local disk.** Without a session filesystem, `directory` is a path on the machine or container that runs the runtime. The model can read the files with any file tool, and it can run scripts. +* **Session filesystem ([AUTOTITLE](/copilot/how-tos/copilot-sdk/setup/multi-tenancy#sessionfs)).** With a `sessionFs` handler, `directory` uses the handler's path conventions, and your handler serves the files; the runtime lists the folder through it. `view`, `create`, `edit`, and `apply_patch` reach the files. `grep`, `glob`, and shell commands don't, so scripts can't run. The Java SDK has no `sessionFs`, so Java providers use local disk. + +The runtime lists the folder after read skill returns, so you can create the folder lazily in read skill. If the folder is missing or empty, or can't be listed within 15 seconds of the runtime calling read skill, the model still gets the base directory line, with no related-files list, as for a file-based skill. If your `sessionFs` handler is disconnected when the runtime lists skills, those skills are text-only until the next listing. + +#### Access + +* **Local disk.** From the time skills are listed, the runtime treats each enabled provider skill's `directory` like a configured `skillDirectories` folder: reads under it skip the outside-workspace path prompt, and sandboxed processes get read-only access to it. Reads still go to your permission handler, as they do for file-based skills. Don't return a broad directory, such as your home folder or a folder that holds every tenant's files. +* **Session filesystem.** The runtime adds no access for the folder. Have your permission handler approve reads under it, and when the sandbox is on, add the folder to the sandbox's `readonlyPaths`. + +Your application owns the folder: where it lives, how long it exists, cleaning it up, and keeping the model from changing it. The read-only access covers only sandboxed processes. Outside the sandbox, or when the folder is inside the workspace, the model can write to it unless your permission handler, or your `sessionFs` handler, refuses. + +#### Writing the skill + +* Refer to supporting files by their path relative to the skill's folder, such as `references/guide.md`. The model is told the folder's absolute path. +* Run scripts through an interpreter, such as `python3 scripts/changelog.py` or `bash scripts/build.sh`, as [Using scripts in skills](https://agentskills.io/skill-creation/using-scripts) describes. Scripts run only when the folder is on local disk. + +#### Rules and limits + +* `directory` must be absolute in the session's path conventions; on a Windows-style session filesystem it needs a drive or UNC prefix. It's resolved the way file tools resolve paths, so `.` and `..` segments and repeated or trailing separators are removed, and it can't resolve to a filesystem root. It counts toward the catalog's 1 MiB metadata limit, and it isn't shown in skill catalogs. An invalid `directory` rejects the whole catalog, like any other invalid listed skill. +* The related-files list follows the file-based rules: build and dependency folders, such as `node_modules`, `bin`, `obj`, and `dist`, are hidden; a folder with more than 50 entries is summarized; files nested more than 10 folders deep are not listed; `SKILL.md` is never listed; and at most 100 files are shown. +* After the conversation is compacted, the model keeps the skill's instructions but not its folder path. Invoking the skill again shows it. File-based skills keep their path. +* Supporting files need a Copilot CLI at least as new as the SDK release that adds them. An older CLI rejects the whole skill catalog when any listed skill has a `directory`. Providers that don't set `directory` work with older CLIs. + +### Serving existing SKILL.md files + +To serve `SKILL.md` files that you already have, such as files stored in a database, parse each file's YAML frontmatter once to build its catalog entry, then return the file unchanged from read skill: + +* Map `name` and `description` to the listed skill's `name` and `description`. Both are required in the catalog; the runtime doesn't fall back to a folder name or the skill body. +* Map `user-invocable`, `disable-model-invocation`, and `argument-hint` to the matching optional fields when the frontmatter sets them. +* Return the original text, frontmatter included, from read skill. Because the listed values came from the same frontmatter, they match, and the runtime still reads `allowed-tools` from it. + +### Trusting provider content + +Treat provider skills like skill directories: their content is trusted input. The model follows a skill's instructions, and the frontmatter controls how the skill is invoked. `allowed-tools` doesn't grant permissions in SDK sessions. The runtime reports it in the `allowedTools` field of the `skill.invoked` event, and your permission handler still decides every tool request. + +Don't serve text that end users or other tenants can edit unless you would let them author a skill file. If you build skills from user input, generate the frontmatter yourself instead of passing user-supplied frontmatter through. + +### Provider limits and errors + +The runtime validates the provider's catalog and content: + +* Names must match `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` and be unique, ignoring case. +* A catalog can contain at most 1,024 skills and 1 MiB of metadata. Each description and argument hint can be at most 1,024 characters. +* Each skill's markdown can be at most 1 MiB of UTF-8. +* Each call must complete within 30 seconds. When the runtime stops waiting for a call, because it timed out, the session ended, or a resume replaced the provider, it cancels the request and ignores any later result. Each SDK passes that cancellation to your provider in its usual way: + + | SDK | Cancellation signal | + |---|---| + | Node.js | The `signal` (`AbortSignal`) in the options passed to `listSkills` and `readSkill` is aborted. | + | Python | The provider's `asyncio` task is cancelled with `asyncio.CancelledError`. Synchronous providers run to completion. | + | Go | The call's `context.Context` is cancelled. | + | .NET | The call's `CancellationToken` is cancelled. | + | Java | The returned `CompletableFuture` is cancelled with `cancel(true)`, which doesn't interrupt running work. | + | Rust | The provider future is dropped. | + + These signals cover calls that the runtime cancels. When the connection closes or the client is force-stopped, the .NET, Java, and Rust SDKs also cancel calls that are still running. The Node.js, Python, and Go SDKs don't: a running call continues until it returns, and the SDK discards its result. + +If listing fails or returns an invalid catalog, the runtime reports the problem in the `errors` list returned by `skills.reload`. A failed reload keeps the provider skills from the last successful list. If reading a skill fails, the `skill` tool reports a generic load failure to the model. The SDK never forwards the text of errors your provider throws or returns, so that text can't leak into the conversation. + +### Provider lifecycle + +A provider is bound to one session: + +* A provider enables skills unless you set `enableSkills` to `false`, which keeps the provider bound but never called. In `mode: "empty"`, skills stay disabled until you set `enableSkills` to `true`. +* The provider is never persisted. Pass it again when you resume a session. Resuming without a provider removes it from the session. Extensions that join the session don't change it. +* A provider skill with the same name as a file-based skill replaces the file-based skill, and `skills.reload` reports a warning. +* Sub-agents use their parent session's provider. +* The runtime can call the provider concurrently, for example from the `skill` tool, user invocations, and custom agents that preload skills, so both operations must be safe for concurrent use. +* Cloud sessions don't support skill providers. The SDK rejects the configuration before it creates the session. + ## Configuration options ### SessionConfig skill fields @@ -296,12 +689,18 @@ The markdown body contains the instructions that are injected into the session c |----------|-------|------|-------------| | Node.js | `skillDirectories` | `string[]` | Directories to load skills from | | Node.js | `disabledSkills` | `string[]` | Skills to disable | +| Node.js | `skillProvider` | `SkillProvider` | Experimental provider for SDK-supplied skills | | Python | `skill_directories` | `list[str]` | Directories to load skills from | | Python | `disabled_skills` | `list[str]` | Skills to disable | +| Python | `skill_provider` | `SkillProvider` | Experimental provider for SDK-supplied skills | | Go | `SkillDirectories` | `[]string` | Directories to load skills from | | Go | `DisabledSkills` | `[]string` | Skills to disable | +| Go | `SkillProvider` | `SkillProvider` | Experimental provider for SDK-supplied skills | | .NET | `SkillDirectories` | `List` | Directories to load skills from | | .NET | `DisabledSkills` | `List` | Skills to disable | +| .NET | `SkillProvider` | `ISkillProvider` | Experimental provider for SDK-supplied skills | +| Java | `setSkillProvider` | `SkillProvider` | Experimental provider for SDK-supplied skills | +| Rust | `with_skill_provider` | `Arc` | Experimental provider for SDK-supplied skills | ### Built-in skills and `mode: "empty"` @@ -319,6 +718,22 @@ casing) to their names. You can also opt into your **own** custom skills under remain fully usable, including a custom skill that shares a name with a built-in. Under `mode: "copilot-cli"` the field is omitted unless you set the option. +Custom skill directories are scanned when skills first load, including directories +that are missing or empty at that point. Filesystem changes in those directories +do not automatically refresh the catalog on later turns. After adding, editing, +or removing skills, call your session's `skills.reload` RPC: + +| SDK | Reload call | +|-----|-------------| +| Node.js | `await session.rpc.skills.reload()` | +| Python | `await session.rpc.skills.reload()` | +| Go | `session.RPC.Skills.Reload(ctx)` | +| .NET | `await session.Rpc.Skills.ReloadAsync()` | +| Java | `session.getRpc().skills.reload().join()` | +| Rust | `session.rpc().skills().reload().await?` | + +Changing the configured skill directories also refreshes the catalog. + ## Best practices 1. **Organize by domain** - Group related skills together (e.g., `skills/security/`, `skills/testing/`) diff --git a/content/copilot/how-tos/copilot-sdk/features/streaming-events.md b/content/copilot/how-tos/copilot-sdk/features/streaming-events.md index 40df8147cd41..50092a6d26a7 100644 --- a/content/copilot/how-tos/copilot-sdk/features/streaming-events.md +++ b/content/copilot/how-tos/copilot-sdk/features/streaming-events.md @@ -430,12 +430,31 @@ Emitted when a tool begins executing. ### `tool.execution_partial_result` -Ephemeral. Incremental output from a running tool (e.g., streaming bash output). +Deprecated. Use `tool.shell_output` for live terminal output. This ephemeral event remains emitted alongside the replacement during migration; subscribe to only one representation to avoid displaying the same output twice. | Data Field | Type | Required | Description | |------------|------|----------|-------------| | `toolCallId` | `string` | ✅ | Matches the corresponding `tool.execution_start` | -| `partialOutput` | `string` | ✅ | Incremental output chunk | +| `partialOutput` | `string` | ✅ | Bounded, cumulative replacement snapshot with stdout and stderr merged; not an append-only chunk | + +### `tool.shell_output` + +Ephemeral. Append-only output from an attached terminal command or a user-requested terminal command. Each event contains newly observed, secret-filtered text, without the separators or markers used to format final tool results for the model. + +| Data Field | Type | Required | Description | +|------------|------|----------|-------------| +| `toolCallId` | `string` | ✅ | Identifies the originating tool call | +| `text` | `string` | ✅ | New text to append, not a replacement snapshot | +| `stream` | `"stdout" \| "stderr" \| "terminal"` | | Output source; omitted means `"stdout"`. `"terminal"` identifies inherently merged PTY output, whose stdout/stderr origin cannot be recovered | +| `sequence` | `number` | ✅ | Starts at zero and increases across all streams for this tool call, in observed publication order | + +Accumulate `text` separately by `toolCallId` and `stream`, or join the chunks in sequence order for a combined display. A sequence number identifies duplicates and missing chunks; it does not establish exact ordering between independent operating-system writes. A late subscriber can receive a first sequence greater than zero. + +These events are live-only: they are not persisted or replayed after resume. Final `tool.execution_complete` results, model-facing formatting, and detached command capture are unchanged. Text can contain terminal control sequences; renderers must apply their normal terminal-text handling. + +An attached terminal command can keep running after `tool.execution_complete` when it runs asynchronously or continues beyond `initial_wait`. Output received while it continues uses the original `toolCallId` and continues the same `sequence`, rather than restarting at zero. Consumers that display only active calls must ignore chunks received after completion instead of recreating completed-call state. + +Secret filtering operates on each decoded chunk. The runtime does not artificially split lines, but operating-system reads can divide a line or secret across chunks; cross-chunk secret reconstruction is not provided. ### `tool.execution_progress` @@ -457,7 +476,7 @@ Emitted when a tool finishes executing—successfully or with an error. | `model` | `string` | | Model that generated this tool call | | `interactionId` | `string` | | CAPI interaction ID | | `isUserRequested` | `boolean` | | `true` when the user explicitly requested this tool call | -| `result` | `Result` | | Present on success (see below) | +| `result` | `Result` | | Present on success, and on failure when the tool returned content with its error (see below) | | `error` | `{ message, code? }` | | Present on failure | | `toolTelemetry` | `object` | | Tool-specific telemetry (e.g., CodeQL check counts) | | `parentToolCallId` | `string` | | Deprecated. Use envelope-level `agentId` for sub-agent attribution | @@ -613,7 +632,7 @@ The `permissionRequest` is a discriminated union on `kind`: | `kind` | Key Fields | Description | |--------|------------|-------------| | `"shell"` | `fullCommandText`, `intention`, `commands[]`, `possiblePaths[]` | Execute a shell command | -| `"write"` | `fileName`, `diff`, `intention`, `newFileContents?` | Write/modify a file | +| `"write"` | `fileName`, `diff`, `intention`, `newFileContents?`, `fileEdits?` | Write/modify a file | | `"read"` | `path`, `intention` | Read a file or directory | | `"mcp"` | `serverName`, `toolName`, `toolTitle`, `args?`, `readOnly` | Call an MCP tool | | `"url"` | `url`, `intention` | Fetch a URL | @@ -622,6 +641,10 @@ The `permissionRequest` is a discriminated union on `kind`: All `kind` variants also include an optional `toolCallId` linking back to the tool call that triggered the request. +Write requests can also include `fileEdits` in both `permissionRequest` and `promptRequest`. Each entry contains an optional `before` and `after` snapshot, each with an absolute `path` in the session filesystem namespace and complete UTF-8 `content`. A missing `before` means creation; a missing `after` means deletion. An empty `content` string represents an empty file, not an unavailable preview. A move can have different paths on its two sides. When a move overwrites an existing destination, separate entries describe the source deletion and destination replacement. + +These snapshots describe the planned operation, not a later read of the live filesystem. The runtime omits `fileEdits` when it cannot provide a complete text preview, including requests made before reading a sandbox-protected file, non-UTF-8 contents, or an unreadable or non-regular move destination, such as a named pipe. Do not infer a deletion from an empty legacy `newFileContents` value. The existing fields and approval choices remain available to consumers that do not use previews. + ### `permission.completed` A permission request was resolved. @@ -735,7 +758,7 @@ A skill was activated for the current conversation. | `name` | `string` | ✅ | Skill name | | `path` | `string` | ✅ | File path to the SKILL.md definition | | `content` | `string` | ✅ | Full skill content injected into the conversation | -| `allowedTools` | `string[]` | | Tools auto-approved while this skill is active | +| `allowedTools` | `string[]` | | Tools listed in the skill's `allowed-tools` frontmatter. The SDK doesn't approve them automatically; your permission handler still decides. The Copilot CLI auto-approves them while the skill is active. | | `pluginName` | `string` | | Plugin the skill originated from | | `pluginVersion` | `string` | | Plugin version | @@ -868,7 +891,7 @@ assistant.turn_start → Turn begins │ ├── permission.requested → Needs user approval │ ├── permission.completed → Approval result │ ├── tool.execution_start → Tool begins -│ ├── tool.execution_partial_result → Streaming tool output (ephemeral, repeated) +│ ├── tool.shell_output → Stream-tagged terminal output (ephemeral, repeated) │ ├── tool.execution_progress → Progress updates (ephemeral, repeated) │ ├── tool.execution_complete → Tool finished │ │ @@ -896,6 +919,7 @@ This table lists key `data` payload fields. Common envelope fields are documente | `tool.user_requested` | | Tool | `toolCallId`, `toolName`, `arguments?` | | `tool.execution_start` | | Tool | `toolCallId`, `toolName`, `arguments?`, `mcpServerName?` | | `tool.execution_partial_result` | ✅ | Tool | `toolCallId`, `partialOutput` | +| `tool.shell_output` | ✅ | Tool | `toolCallId`, `text`, `stream?`, `sequence` | | `tool.execution_progress` | ✅ | Tool | `toolCallId`, `progressMessage` | | `tool.execution_complete` | | Tool | `toolCallId`, `success`, `result?`, `error?` | | `session.idle` | ✅ | Session | `aborted?` | diff --git a/content/copilot/how-tos/copilot-sdk/features/usage-and-billing.md b/content/copilot/how-tos/copilot-sdk/features/usage-and-billing.md index 5f8983b20ccf..bda69118403f 100644 --- a/content/copilot/how-tos/copilot-sdk/features/usage-and-billing.md +++ b/content/copilot/how-tos/copilot-sdk/features/usage-and-billing.md @@ -1,6 +1,6 @@ --- title: Usage and billing metrics -shortTitle: Usage and billing +shortTitle: Usage And Billing intro: >- This guide shows how to read token counts, context-window utilization, AI credit cost, and account quota from a Copilot SDK application. Examples are @@ -34,9 +34,21 @@ The table below maps each signal to the API that exposes it. | Accumulated AI credit and token totals | `session.usage.getMetrics` | Session | RPC | | Per-model AI credit pricing | `models.list` | Server | RPC | | Account quota and premium interactions | `account.getQuota` | Server | RPC | +| Session-owned provider quota observations | `session.quota.get` and `session.quota.refresh` | Session | RPC | +| Provider admission-state updates | `session.quota_observation` | Session | Event | + +`session.quota.get` reads local runtime state; `session.quota.refresh` refreshes the provider catalogs and quota sources. Both return `providerQuotas`, keyed by provider identity, quota ID, and observation kind. The runtime keeps one observation store. The legacy `snapshots` field is a derived compatibility view of authoritative budgets, not a second source of quota state. + +Provider cardinality is an emergent property of actual usage and observations supplied to the seam. This API neither knows nor enforces how many providers a user may use: it records and projects zero, one, or many entries identically. Producers supply stable provider/account-scoped references; usage adds model identity, and quota adds quota identity and observation kind. `get()` returns the full known collection, so callers can select an exact key, a subset, or all entries without provider-specific methods. Acquisition uses the sources supplied by the upstream account/model layer; it does not manufacture a union or impose a selected-provider filter on reads. + +Check `observationKind`, `quantityKind`, and `acquisitionStatus` before interpreting a reading. An `authoritative_budget` can supply progress and reset metadata. An `advisory_balance` reports independent signed 64-bit quantities without promising a percentage, reset window, or post-call balance. An `admission_state` reports permission/capacity at request admission and carries no quantities; it does not replace an account balance. Preserve missing values and explicit nulls rather than converting them to zero. A failed or unavailable acquisition is not quota exhaustion. + +For Microsoft 365 Copilot, access and capacity are service verdicts, while quantities are advisory. For example, `allowed`/`available` with both quantities explicitly zero means **0 / 0 AIC reported**, not 100% consumed or inconsistent state. A missing entitlement is different from a zero entitlement. The `-1` entitlement sentinel means unlimited entitlement without changing the reported capacity state. Inference headers can reflect cached admission state; neither those headers nor an on-demand quota refresh establish the charge for the preceding call. + +Usage totals belong to a session, not the active account. In the CLI, switching away from a non-empty Microsoft 365 Copilot session requires starting a new session. The previous session retains its usage and history for resume; the new session starts its own totals. > [!NOTE] -> `session.usage.getMetrics`, `session.metadata.contextInfo`, and `session.metadata.recomputeContextTokens` are marked experimental in the generated RPC surface. In .NET they raise the `GHCP001` experimental diagnostic, which you suppress with `#pragma warning disable GHCP001` or a project-level `GHCP001`. Pin both the SDK and the Copilot CLI runtime if your application depends on them. +> `session.quota`, `session.usage.getMetrics`, `session.metadata.contextInfo`, and `session.metadata.recomputeContextTokens` are marked experimental in the generated RPC surface. In .NET they raise the `GHCP001` experimental diagnostic, which you suppress with `#pragma warning disable GHCP001` or a project-level `GHCP001`. Pin both the SDK and the Copilot CLI runtime if your application depends on them. The field tables below list only the fields used in the examples on this page. The complete, always-current field reference is the generated SDK types plus [AUTOTITLE](/copilot/how-tos/copilot-sdk/features/streaming-events), which is regenerated from the CLI schema on every dependency bump. Treat those as the source of truth and this page as a task-oriented guide. @@ -376,7 +388,7 @@ The example uses the fields below. The generated `UsageGetMetricsResult` type is | `modelMetrics` | `Record` | Per-model breakdown; each entry has `usage.inputTokens`, `usage.outputTokens`, and `totalNanoAiu` | > [!NOTE] -> Cost is reported in **nano-AI units** (the field is named `totalNanoAiu`). The exact conversion to AI credits and the precise meaning of premium request accounting are defined by GitHub Copilot billing, not by the SDK—treat [GitHub's Copilot billing documentation](/copilot/concepts/billing-and-usage) as the source of truth and verify before surfacing currency-like values to users. The examples divide by `1e9` as a convenience, following the SI `nano` prefix; confirm this matches current billing before relying on it. The `modelMetrics` and `tokenDetails` maps are keyed by runtime strings (model IDs and token-type names) that the SDK type system does not validate. +> Cost is reported in **nano-AI units** (the field is named `totalNanoAiu`). The exact conversion to AI credits and the precise meaning of premium request accounting are defined by GitHub Copilot billing, not by the SDK—treat [GitHub's Copilot billing documentation](/copilot/managing-copilot/understanding-and-managing-copilot-usage) as the source of truth and verify before surfacing currency-like values to users. The examples divide by `1e9` as a convenience, following the SI `nano` prefix; confirm this matches current billing before relying on it. The `modelMetrics` and `tokenDetails` maps are keyed by runtime strings (model IDs and token-type names) that the SDK type system does not validate. {% codetabs %} {% codetab typescript %} diff --git a/content/copilot/how-tos/copilot-sdk/hooks/hooks-overview.md b/content/copilot/how-tos/copilot-sdk/hooks/hooks-overview.md index f9dfe320ff4d..b9b8822b3de4 100644 --- a/content/copilot/how-tos/copilot-sdk/hooks/hooks-overview.md +++ b/content/copilot/how-tos/copilot-sdk/hooks/hooks-overview.md @@ -21,6 +21,9 @@ contentType: how-tos * **Handle errors** - implement custom error handling * **Audit and log** - track all interactions for compliance +For filesystem hooks loaded from additional startup directories rather than +registered SDK callbacks, see [AUTOTITLE](/copilot/how-tos/copilot-sdk/features/hooks#repository-hooks-from-additional-directories). + ## Available hooks | Hook | Trigger | Use Case | diff --git a/content/copilot/how-tos/copilot-sdk/hooks/session-lifecycle.md b/content/copilot/how-tos/copilot-sdk/hooks/session-lifecycle.md index fa73a3bd9f4b..5d06004a946b 100644 --- a/content/copilot/how-tos/copilot-sdk/hooks/session-lifecycle.md +++ b/content/copilot/how-tos/copilot-sdk/hooks/session-lifecycle.md @@ -204,6 +204,8 @@ const session = await client.createSession({ The `onSessionEnd` hook is called when a session ends. +A standalone SDK session remains active between prompts. Completing a prompt does not end the session; disconnect or dispose the session to run its end hook. + ### Hook signature {% codetabs %} diff --git a/content/copilot/how-tos/copilot-sdk/hooks/user-prompt-submitted.md b/content/copilot/how-tos/copilot-sdk/hooks/user-prompt-submitted.md index ada14910f7e0..62ff4ab631fd 100644 --- a/content/copilot/how-tos/copilot-sdk/hooks/user-prompt-submitted.md +++ b/content/copilot/how-tos/copilot-sdk/hooks/user-prompt-submitted.md @@ -93,6 +93,28 @@ Return `null` or `undefined` to use the prompt unchanged. Otherwise, return an o | `modifiedPrompt` | string | Modified prompt to use instead of original | | `additionalContext` | string | Extra context added to the conversation | | `suppressOutput` | boolean | If true, suppress the assistant's response output | +| `decision` | `"block"` | Refuse the prompt (see [Blocking a prompt](#blocking-a-prompt)) | +| `reason` | string | Why the prompt was blocked; required with `decision` | + +### Blocking a prompt + +Return a block decision to refuse the prompt: + +```json +{ + "decision": "block", + "reason": "Prompts may not include credentials." +} +``` + +No model turn runs and no user message is recorded. The session emits an ephemeral `session.warning` with `warningType: "policy_blocked"` that carries the reason. The public member names follow each language's conventions: + +| Meaning | Node.js / Python | Go / .NET | Rust | Java | +|---------|------------------|-----------|------|------| +| Block the prompt | `decision: "block"` | `Decision = "block"` | `decision: Some("block".into())` | `UserPromptSubmittedHookOutput.block(reason)` | +| Why it was blocked | `reason` | `Reason` | `reason` | the `reason` argument | + +In Go, an empty `Reason` is omitted from the output, and the prompt is blocked only when a reason is present. ## Examples @@ -243,10 +265,10 @@ const session = await client.createSession({ onUserPromptSubmitted: async (input) => { for (const pattern of BLOCKED_PATTERNS) { if (pattern.test(input.prompt)) { - // Replace the prompt with a warning message + // Refuse the prompt; the reason reaches the client as a session.warning return { - modifiedPrompt: "[Content blocked: Please don't include sensitive credentials in your prompts. Use environment variables instead.]", - suppressOutput: true, + decision: "block", + reason: "Please don't include sensitive credentials in your prompts. Use environment variables instead.", }; } } diff --git a/content/copilot/how-tos/copilot-sdk/hooks/user-prompt-transformed.md b/content/copilot/how-tos/copilot-sdk/hooks/user-prompt-transformed.md index 51c366643e64..0e4f16eb2800 100644 --- a/content/copilot/how-tos/copilot-sdk/hooks/user-prompt-transformed.md +++ b/content/copilot/how-tos/copilot-sdk/hooks/user-prompt-transformed.md @@ -1,6 +1,6 @@ --- title: User prompt transformed hook -shortTitle: User prompt transformed +shortTitle: User Prompt Transformed intro: >- The `userPromptTransformed` hook runs after the runtime adds generated context to a submitted prompt, but before the resulting content is persisted to diff --git a/content/copilot/how-tos/copilot-sdk/observability/opentelemetry.md b/content/copilot/how-tos/copilot-sdk/observability/opentelemetry.md index 528ab7dad068..9de5647e5f61 100644 --- a/content/copilot/how-tos/copilot-sdk/observability/opentelemetry.md +++ b/content/copilot/how-tos/copilot-sdk/observability/opentelemetry.md @@ -120,6 +120,53 @@ let client = Client::start(ClientOptions::new() The OTLP protocol field configures the CLI's `"otlp-http"` exporter for all signals. Leave it unset to use the CLI default, or set it to `"http/protobuf"` to export protobuf over HTTP. +### Skill and command telemetry + +The runtime follows the [GenAI skill semantic conventions](https://github.com/open-telemetry/semantic-conventions-genai/commit/771e3210f7518694666834f7243573b8018c7a4f) +by refining existing `execute_tool` spans, not adding a separate skill +operation. A loader is named `execute_tool skill {skill}`. An unambiguously +identified resource or script is named +`execute_tool {tool} {skill} {resource}`, with a skill-relative resource name. +Other command spans append the actual known launcher name. + +* `gen_ai.skill.name` and `gen_ai.skill.resource.name`: Available independently of content capture +* `gen_ai.skill.description` and `gen_ai.skill.source.uri`: Require content capture through `TelemetryConfig` +* `process.executable.name`: The directly launched executable, such as the shell launcher, not a guessed command token +* `process.executable.path`: An absolute known path; requires content capture +* `process.exit.code`: An integer, including zero, when a command-executing tool reports an exit; unavailable exits and pre-spawn failures are omitted + +Poll tools do not inherit process attributes from the command they read. +Sandboxed executable identities are omitted when the sandbox backend does +not expose them. A nonzero exit does not imply that the tool itself failed. +Skill definitions and directory listings are not named skill resources. +Preloaded skills produce invocation events without a fabricated tool span. +Repeated preparation of the same selected-agent skill context emits one receipt; +changed content or provenance emits another. Child sessions report their own +preloads independently. Detached command spans identify the directly spawned +Unix `sh` supervisor or selected Windows PowerShell executable. + +Custom tools that override built-in names do not inherit native skill or process +attributes. Visible, enabled skills still own resources when model invocation +is disabled. Script attribution uses the post-hook command and the selected +shell's directory; it is omitted when startup scripts or profiles make the +execution context uncertain. + +While managed telemetry settings are unresolved, pending tracking buffers only +capture-independent tool facts. Descriptions, source URIs, and executable paths +observed before the policy resolves are omitted, even if capture is later enabled. +The final policy governs replay; disabled or cancelled tracking discards the buffer. + +These are breaking telemetry-name changes: use `gen_ai.skill.name` instead +of `github.copilot.tool.parameters.skill_name` or +`github.copilot.skill.name`, and use `gen_ai.skill.source.uri` instead of +`github.copilot.skill.path`. Local source values are file URIs, not bare paths. +The `github.copilot.skill.invoked` event and its content, source-category, +trigger, and plugin fields remain Copilot-specific. Skill content retains its +existing capture gate. + +The skill attributes and span refinements have Development stability; the +process attributes have Release Candidate stability. + ### Trace context propagation > **Most users don't need this.** The `TelemetryConfig` above is all you need to collect traces from the CLI. The trace context propagation described in this section is an **advanced feature** for applications that create their own OpenTelemetry spans and want them to appear in the **same distributed trace** as the CLI's spans. diff --git a/content/copilot/how-tos/copilot-sdk/setup/azure-managed-identity.md b/content/copilot/how-tos/copilot-sdk/setup/azure-managed-identity.md index cad294875432..8396b4ffbe10 100644 --- a/content/copilot/how-tos/copilot-sdk/setup/azure-managed-identity.md +++ b/content/copilot/how-tos/copilot-sdk/setup/azure-managed-identity.md @@ -1,5 +1,5 @@ --- -title: Azure managed identity with BYOK +title: Azure Managed Identity with BYOK shortTitle: Azure Managed Identity intro: >- The GitHub Copilot SDK's [AUTOTITLE](/copilot/how-tos/copilot-sdk/auth/byok) supports static API diff --git a/content/copilot/how-tos/copilot-sdk/setup/in-process-runtime.md b/content/copilot/how-tos/copilot-sdk/setup/in-process-runtime.md index 5d5c50f5bc3b..e4c9ff2b1064 100644 --- a/content/copilot/how-tos/copilot-sdk/setup/in-process-runtime.md +++ b/content/copilot/how-tos/copilot-sdk/setup/in-process-runtime.md @@ -1,6 +1,6 @@ --- title: Run the Copilot runtime in process -shortTitle: In-process runtime +shortTitle: In Process Runtime intro: >- In-process hosting loads the native Copilot runtime into your application process instead of starting a separate Copilot CLI process. Use it to remove diff --git a/content/copilot/how-tos/copilot-sdk/setup/multi-tenancy.md b/content/copilot/how-tos/copilot-sdk/setup/multi-tenancy.md index 30d968fbfcbd..16a90567230d 100644 --- a/content/copilot/how-tos/copilot-sdk/setup/multi-tenancy.md +++ b/content/copilot/how-tos/copilot-sdk/setup/multi-tenancy.md @@ -237,6 +237,24 @@ const client = new CopilotClient({ For languages that expose a provider callback, configure `sessionFs` at the client level and provide a per-session filesystem handler when creating or resuming a session. See [AUTOTITLE](/copilot/how-tos/copilot-sdk/features/session-persistence) for persistence concepts and storage trade-offs. +Runtime-owned durable permission choices also use the session filesystem. Under the provider's `sessionStatePath`, `permissions.json` retains its top-level `locations` map with `tool_approvals` and `allowed_directories`; `settings.json` stores top-level `allowedUrls` and `sandbox`; and `config.json` stores top-level `trustedFolders`. + +URL approvals are read only from `settings.json`; legacy user-setting fields such as `allowedUrls` in `config.json` are ignored, matching host behavior. + +Location keys are normalized provider directory paths, with no added prefix. Resolution follows the provider's path conventions without inspecting the host filesystem or discovering host Git repositories. `/workspace` and `/workspace/sub` select different location entries. + +The runtime does not add session-ID partitions to these files or change their sharing granularity. Sessions accessing the same files in the same provider namespace share remembered grants. Your application must use distinct namespaces for tenants or sessions that require isolation, authorize access to each namespace, and reconnect resumed sessions to their original namespace. + +With a separate namespace per session, permanent grants survive that session's resumes rather than becoming tenant-wide grants for new sessions. Sharing grants across sessions requires sharing the consent files while keeping other session state separate. The runtime serializes consent updates only within the same provider endpoint and process; applications that share backing files across endpoints or processes must coordinate concurrent updates. + +The runtime blocks direct provider-backed file writes to the lexical approval-metadata paths as defense in depth, not as a tamper-proof storage boundary. Providers must not expose writable aliases (such as symlinks or Windows 8.3 short names) to approval metadata through agent file tools. Shell tools do not run through SessionFS, and their commands are not checked against its virtual metadata paths. If your application separately exposes the provider's backing storage to shell execution or other arbitrary code, your host must protect that storage. + +Session filesystem approval operations never import or fall back to remembered permissions from the host's `permissions-config.json`, URL or sandbox settings from `settings.json`, or folder trust from `config.json`. They do not update those host files, even when the provider has no approvals or reports an error. A provider error is not permission to use host consent. URL deny rules remain authoritative, and folder trust uses provider paths without host Git discovery. + +Approve-once, approve-for-session, rejections, and session policy changes retain their existing lifetimes and event-replay behavior. Storage routing does not turn them into new permanent grants. Protect session transcripts, including durable permission events, with the same tenant isolation as approval metadata. + +This boundary applies only to sessions using a session filesystem provider. All SDK applications without `sessionFs`, in both `empty` and `copilot-cli` modes, and ordinary CLI sessions retain existing host-backed persistence for remembered locations, permanent URL approvals, folder trust, and sandbox settings. + Verified public SDK surfaces: | Language | Client-level config | Per-session provider | diff --git a/content/copilot/how-tos/copilot-sdk/troubleshooting/compatibility.md b/content/copilot/how-tos/copilot-sdk/troubleshooting/compatibility.md index 5a1f9a6cd656..49a6dfbcb683 100644 --- a/content/copilot/how-tos/copilot-sdk/troubleshooting/compatibility.md +++ b/content/copilot/how-tos/copilot-sdk/troubleshooting/compatibility.md @@ -46,6 +46,7 @@ The Copilot SDK communicates with the CLI via JSON-RPC protocol. Features must b | Abort | `abort()` | Cancel in-flight request | | **Tools** | | | | Register custom tools | `registerTools()` | Full JSON Schema support | +| Replace custom tools (mid-session) | `session.setTools()` | Experimental; see [AUTOTITLE](/copilot/how-tos/copilot-sdk/features/changing-tools) | | Tool permission control | `onPreToolUse` hook | Allow/deny/ask | | Tool result modification | `onPostToolUse` hook | Transform results | | Available/excluded tools | `availableTools`, `excludedTools` config | Filter tools | diff --git a/content/copilot/how-tos/copilot-sdk/troubleshooting/debugging.md b/content/copilot/how-tos/copilot-sdk/troubleshooting/debugging.md index a5f009acbfae..4657f63d057a 100644 --- a/content/copilot/how-tos/copilot-sdk/troubleshooting/debugging.md +++ b/content/copilot/how-tos/copilot-sdk/troubleshooting/debugging.md @@ -99,7 +99,14 @@ var client = new CopilotClient(new CopilotClientOptions() ### Log directory -The CLI writes logs to a directory. You can specify a custom location: +The SDK-managed out-of-process runtime writes redacted +`process--.log` files under the Copilot home's `logs` directory +by default in `copilot-cli` mode. SDKs in `empty` mode do not set the logging +opt-in, but an inherited setting can still enable it; session events remain +available to `collectLogs`. An explicitly selected older CLI executable may +ignore this setting. You can specify a custom log location with `--log-dir`; +`collectLogs` searches the configured directory. In-process connections do +not start a separate logging process. {% codetabs %} {% codetab typescript %} @@ -114,13 +121,12 @@ const client = new CopilotClient({ {% codetab python %} ```python -# The Python SDK does not currently support passing extra CLI arguments. -# Logs are written to the default location or can be configured via -# the CLI when running in server mode. -``` +from copilot import CopilotClient, RuntimeConnection -> [!NOTE] -> Python SDK logging configuration is limited. For advanced logging, run the CLI manually with `--log-dir` and connect via `RuntimeConnection.for_uri(...)`. +client = CopilotClient( + connection=RuntimeConnection.for_stdio(args=["--log-dir", "/path/to/logs"]) +) +``` {% endcodetab %} {% codetab go %} @@ -149,9 +155,11 @@ var client = new CopilotClient(new CopilotClientOptions ```java -// The Java SDK does not currently support passing extra CLI arguments. -// For custom log directories, run the CLI manually with --log-dir -// and connect via cliUrl. +import com.github.copilot.CopilotClient; +import com.github.copilot.rpc.CopilotClientOptions; + +var client = new CopilotClient(new CopilotClientOptions() + .setCliArgs(new String[] {"--log-dir", "/path/to/logs"})); ``` {% endcodetab %} @@ -165,7 +173,7 @@ var client = new CopilotClient(new CopilotClientOptions **Solution:** -1. Install the CLI: [Installation guide](/copilot/how-tos/copilot-cli/set-up-copilot-cli/install-copilot-cli) +1. Install the CLI: [Installation guide](/copilot/how-tos/set-up/install-copilot-cli) 1. Verify installation: diff --git a/content/copilot/reference/copilot-cli-reference/cli-command-reference.md b/content/copilot/reference/copilot-cli-reference/cli-command-reference.md index 752bb2db10f3..ab98192e8121 100644 --- a/content/copilot/reference/copilot-cli-reference/cli-command-reference.md +++ b/content/copilot/reference/copilot-cli-reference/cli-command-reference.md @@ -577,7 +577,7 @@ These are the slash commands you can use from within an interactive CLI session. | `/logout` | Log out of {% data variables.product.prodname_copilot_short %}. | | `/lsp [show\|test\|reload\|logs\|help] [SERVER-NAME]` | Manage the language server configuration. The `logs` subcommand opens the live LSP services log panel. | | `/mcp [config\|list\|show\|add\|edit\|delete\|disable\|enable\|auth\|reload\|search] [SERVER-NAME]` | Manage the MCP server configuration. With no subcommand, or with `config`, the plugins dashboard opens pinned to the MCP server list; the add, edit, and authenticate forms open inside that dashboard too, so closing a form returns you to the server list. Use `show` or `show SERVER-NAME` to display all configured servers or open one server's details directly, including its available tools, and to enable or disable it. For a plugin-provided server, `show SERVER-NAME` also displays the source attribution (for example, `Source: Plugin my-plugin (1.2.0)`). `list` (alias `ls`) prints a plain-text list of configured servers with connection status and live state. Bare `/mcp`, `config`, `show`, and `list` (alias `ls`) are read-only or open the dashboard, so they can run while the agent is busy processing a turn. The mutating subcommands (`add`, `edit`, `delete`, `disable`, `enable`, `auth`, `reload`, and `search`) are blocked until the turn finishes. `edit ` rejects a workspace-sourced server (one defined in a repository's `.mcp.json`) instead of opening the user-tier wizard, since saving would silently create a same-name user entry that the workspace one still shadows. The error names the file to edit directly. `delete ` reports the same file when asked to remove a workspace-sourced server. Sandboxed local servers show a `connected (sandboxed)` status. See [AUTOTITLE](/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers#managing-mcp-servers). | -| `/model [--session\|--global\|--repo\|--local] [MODEL\|auto TIER]`, `/models` | Select the AI model you want to use, or choose **Auto**. Model IDs are case-insensitive, and the canonical ID is saved. By default (or with `--session`, alias `-s`), changes the model, reasoning effort, or context window for the current session only, without touching saved settings. `--repo`/`--local` pins the default model in repository settings instead; `--global` (or `/config model`) sets the default for future sessions. Press Tab on a model with a long-context variant to toggle its Context column between the default and long-context window. The picker groups models into sections—press Shift+Tab to cycle grouping between recommended (Recent, Recommended, New, and other models), vendor, and category. A model with vendor-specific data retention terms shows a data retention warning banner with a link to the vendor's policy. Usable mid-turn: a change requested while the agent is running is queued as a cancellable (Ctrl+C) command and applied once the current turn finishes, instead of switching the live model mid-request. Use `/model auto TIER` (`efficiency`, `balance`, or `intelligence`) to select a specific Auto routing tier directly, including from the "switch" action on an Auto tier recommendation hint. When Auto picks a model, the timeline shows the reason for that choice whenever the API provides one. See [AUTOTITLE](/copilot/concepts/models/auto-model-selection). | +| `/model [--session\|--global\|--repo\|--local] [MODEL\|auto TIER]`, `/models` | Select the AI model you want to use, or choose **Auto**. Model IDs are case-insensitive, and the canonical ID is saved. By default (or with `--session`, alias `-s`), changes the model, reasoning effort, or context window for the current session only, without touching saved settings. `--repo`/`--local` pins the default model in repository settings instead; `--global` (or `/config model`) sets the default for future sessions. Press Tab on a model with a long-context variant to toggle its Context column between the default and long-context window. The picker groups models into sections—press Shift+Tab to cycle grouping between recommended (Recent, Recommended, New, and other models), vendor, and category. The picker marks your effective managed default model with the default badge when it is selectable. A model with vendor-specific data retention terms shows a data retention warning banner with a link to the vendor's policy. Usable mid-turn: a change requested while the agent is running is queued as a cancellable (Ctrl+C) command and applied once the current turn finishes, instead of switching the live model mid-request. Use `/model auto TIER` (`efficiency`, `balance`, or `intelligence`) to select a specific Auto routing tier directly, including from the "switch" action on an Auto tier recommendation hint. When Auto picks a model, the timeline shows the reason for that choice whenever the API provides one. See [AUTOTITLE](/copilot/concepts/models/auto-model-selection). | | `/permissions [default\|assisted\|allow-all\|show]` | Switch between permission modes (`default`, `assisted`, `allow-all`), or show the current mode (`show`). This is the canonical command for permission mode changes; `/allow-all` and `/yolo` remain supported as aliases. | | `/permissions reset` | Reset all in-memory tool and path approvals for the current session (re-prompt on next use). | | `/plan [PROMPT]` | Create an implementation plan before coding. | @@ -585,7 +585,7 @@ These are the slash commands you can use from within an interactive CLI session. | `/plugin install SOURCE` | Install a plugin from a marketplace spec, {% data variables.product.github %} repository, git URL, or local path. | | `/plugin update PLUGIN[@MARKETPLACE]` | Update an installed plugin. | | `/plugin uninstall PLUGIN[@MARKETPLACE]` (aliases `remove`, `rm`) | Uninstall a plugin. | -| `/plugin list` (alias `ls`) | List installed plugins. | +| `/plugin list` (alias `ls`) | List installed plugins. Team plugin assignments set optional defaults for team members. Plugins assigned as default-enabled appear as enabled, and their skills load, unless you disable them or managed settings override the default. In contrast, plugins enforced through managed `enabledPlugins`, including enterprise team settings, cannot be disabled locally. See [AUTOTITLE](/copilot/reference/copilot-cli-reference/cli-plugin-reference#cli-commands). | | `/plugin marketplace add SOURCE` | Add a marketplace. | | `/plugin marketplace remove NAME` | Remove a marketplace. | | `/plugin marketplace list` | List registered marketplaces. | diff --git a/content/copilot/reference/copilot-cli-reference/cli-config-dir-reference.md b/content/copilot/reference/copilot-cli-reference/cli-config-dir-reference.md index eca68cff4fe4..c25221f5ef1f 100644 --- a/content/copilot/reference/copilot-cli-reference/cli-config-dir-reference.md +++ b/content/copilot/reference/copilot-cli-reference/cli-config-dir-reference.md @@ -670,6 +670,7 @@ Only the following keys are supported in MDM managed settings. | `permissions` | Set managed permissions, including `disableBypassPermissionsMode`, `deny` / `ask` / `allow` rule arrays, and the `limitTo` domain boundary array. See [Managed permission rules](#managed-permission-rules) and [Domain boundary (`limitTo`)](#domain-boundary-limitto). | | `policyHelper` | Register an executable that supplies the lowest-priority managed-settings layer. Fields: `path` (required), plus optional `args`, `timeoutMs`, and `refreshIntervalMs`. If both a device (MDM) and a server policy register a `policyHelper`, the device registration wins. | | `remoteControl` | Control whether sessions on this device can be controlled from other devices. `mode` is `"enabled"`, `"disabled"`, or `"requireSSO"` (requires `githubDotComOrganizations` when set). | +| `requireBase64ImageAttachments` | When `true`, require base64-encoded image attachments instead of attachment URLs. `false` or unset keeps existing behavior. | | `sandbox` | Set a sandbox policy floor that users cannot relax. Supported settings include `enabled`, `failIfUnavailable`, `allowBypass`, `addCurrentWorkingDirectory`, `sandboxMcpServers`, `sandboxLspServers`, `auth.git`, `auth.gh`, `allowDevToolAccess`, `learningMode`, and the `userPolicy.*` filesystem and network rules. The managed value always takes precedence over a user's own value in the safer direction. Turning the sandbox on, requiring it to succeed, and sandboxing MCP and LSP servers cannot be turned off. Disabling bypass or credential injection cannot be re-enabled. Filesystem allow lists can only be narrowed, and denied paths can only be added to. `failIfUnavailable` can only be set by an administrator and blocks the session when the sandbox cannot be established. For the settings users can set themselves, see [User settings](#user-settings-copilotsettingsjson) or run `copilot help sandbox`. | | `shellShortcut` | Force-enable or force-disable the `$` interactive shell shortcut for all users. A managed value always overrides the user's own `shellShortcut` setting. | | `strictKnownMarketplaces` | Restrict plugins to an allowlist of known marketplaces (a JSON array of marketplace specs). The allowlist also governs built-in marketplaces once set—an empty array (`[]`) hides and blocks every marketplace, including built-ins, not just user- or repository-added ones. | @@ -750,7 +751,7 @@ Each entry identifies a server by exactly one of the following matchers. |---------|---------|-------| | `serverUrl` | A remote (HTTP/SSE) server by URL | Supports `*` wildcards; scheme and host match case-insensitively, path case-sensitively. `${VAR}` references expand before matching. | | `serverCommand` | A stdio server by its exact command and arguments, in order | `${VAR}` references expand before matching. | -| `serverName` | A server by its assigned label | Allowlist entries are restricted to `[A-Za-z0-9_-]+` (no wildcards); denylist entries accept any non-empty string. | +| `serverName` | A server by its assigned label | Allowlist entries are restricted to `[A-Za-z0-9_./-]+` (case-sensitive, no wildcards; registry names such as `microsoft/playwright-mcp` are accepted); denylist entries accept any non-empty string. | Rules: diff --git a/content/copilot/reference/enterprise-administrators/enterprise-managed-settings.md b/content/copilot/reference/enterprise-administrators/enterprise-managed-settings.md index 711a1271820d..8b8b9686bd91 100644 --- a/content/copilot/reference/enterprise-administrators/enterprise-managed-settings.md +++ b/content/copilot/reference/enterprise-administrators/enterprise-managed-settings.md @@ -356,7 +356,7 @@ Each entry must contain exactly one matcher property. | Property | Matching behavior | Applicable servers | | --- | --- | --- | -| `serverName` | Matches the user-assigned server label exactly. Wildcards are not supported. Because users choose server names, use `serverUrl` or `serverCommand` when you need to enforce the identity of a server. | Any server. In-memory servers can only use `serverName`. | +| `serverName` | Matches the user-assigned server label exactly. Wildcards are not supported. Allowlist entries are case-sensitive and limited to letters, numbers, and the characters `_`, `.`, `/`, and `-`, so registry names such as `microsoft/playwright-mcp` are accepted. Because users choose server names, use `serverUrl` or `serverCommand` when you need to enforce the identity of a server. | Any server. In-memory servers can only use `serverName`. | | `serverUrl` | Matches a remote server URL. Supports `*` wildcards for subdomains or path prefixes—for example, `https://mcp.example.com/*` or `https://*.internal.example.com/*`. | Remote servers that connect over HTTP or server-sent events (SSE). This property does not apply to local servers, even if they have a URL. | | `serverCommand` | Matches the exact command and each argument for a local server—for example, `["npx", "-y", "my-mcp-server"]`. Wildcards and command-line expansion are not supported. | Local servers that use standard input and output (`stdio`). This property does not apply to remote servers, even if they have a command. | diff --git a/content/migrations/overview/about-locked-repositories.md b/content/migrations/overview/about-locked-repositories.md index 5b33648055a6..ff1edd63b8e1 100644 --- a/content/migrations/overview/about-locked-repositories.md +++ b/content/migrations/overview/about-locked-repositories.md @@ -14,6 +14,8 @@ category: When you migrate repositories to or from {% data variables.product.prodname_dotcom %} products, your origin and destination repositories may be “locked” for migration. While a repository is locked, you cannot make any changes to the repository, such as pushing commits, creating issues, or commenting on pull requests. +While a repository is locked, webhooks are not delivered for any events in the repository. + Whether your repositories will be locked during migration depends on the tooling you use and the options you choose when you run the migration. When a repository is locked, a banner with the following text is displayed on the repository's page on {% data variables.product.prodname_dotcom %}: > This repository is currently being migrated. It's locked while the migration is in progress. diff --git a/data/features/repo-rules-required-reviewer.yml b/data/features/repo-rules-required-reviewer.yml index fb191f7ea00b..e617d1453929 100644 --- a/data/features/repo-rules-required-reviewer.yml +++ b/data/features/repo-rules-required-reviewer.yml @@ -4,4 +4,4 @@ versions: fpt: '*' ghec: '*' - ghes: '>=3.22' + ghes: '>=3.24' diff --git a/data/release-notes/enterprise-server/3-22/0-rc1.yml b/data/release-notes/enterprise-server/3-22/0-rc1.yml index 970503e71326..0e2694b0bafb 100644 --- a/data/release-notes/enterprise-server/3-22/0-rc1.yml +++ b/data/release-notes/enterprise-server/3-22/0-rc1.yml @@ -160,9 +160,6 @@ sections: # https://github.com/github/releases/issues/7902 - | Repository administrators and maintainers can now delete commit comments. Previously, commit comments could not be removed, which allowed spam and abusive comments to persist in repositories. This gives maintainers another tool to reduce spam and unwanted content. For details, see the [changelog](https://github.blog/changelog/2026-03-19-disable-comments-on-individual-commits/). - # https://github.com/github/releases/issues/7340 - - | - Organization owners and repository administrators can require specific reviewers for pull requests targeting a given branch by adding a required reviewers rule to a repository ruleset. This provides more granular control over code ownership than a single `CODEOWNERS` file, which is difficult to scale for large organizations or monorepos. For example, administrators can require QA reviewers on a release branch, security team members on the default branch, or product and design reviewers on feature branches, including setting the minimum number of required reviews per team. # https://github.com/github/releases/issues/6784 - | Developers can find and organize their repositories more efficiently using the new repositories dashboard at `/repos`. The dashboard displays a developer's most active repositories and allows them to create saved views for quick access to the repositories they use most. This gives developers a centralized page for tracking their work and streamlining their workflows. For details, see the [changelog](https://github.blog/changelog/2026-02-24-repository-dashboard-is-now-generally-available/). @@ -262,3 +259,7 @@ sections: # https://github.com/github/releases/issues/6651 - | Starting in 3.22, networking-related syscalls will be disabled by default in the pre-receive hook environment. For enhanced security, hook environments will be placed in dedicated network namespaces. You will be able to override the default setting by setting pre-receive-hook-networking to enabled. In many cases, push rulesets are an alternative for many pre-receive hooks. For more information, see [AUTOTITLE](/enterprise-server@3.22/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets#push-rulesets). + + errata: + - | + These release notes previously included a note about required reviewers rules for repository rulesets. The feature is not available in this release and will be included in a future release. [Updated: 2026-10-19] diff --git a/data/release-notes/enterprise-server/3-22/0.yml b/data/release-notes/enterprise-server/3-22/0.yml index d2a2a077e7c3..f5ebdb07c557 100644 --- a/data/release-notes/enterprise-server/3-22/0.yml +++ b/data/release-notes/enterprise-server/3-22/0.yml @@ -156,9 +156,6 @@ sections: # https://github.com/github/releases/issues/7902 - | Repository administrators and maintainers can now delete commit comments. Previously, commit comments could not be removed, which allowed spam and abusive comments to persist in repositories. This gives maintainers another tool to reduce spam and unwanted content. For details, see the [changelog](https://github.blog/changelog/2026-03-25-disable-comments-on-individual-commits/). - # https://github.com/github/releases/issues/7340 - - | - Organization owners and repository administrators can require specific reviewers for pull requests targeting a given branch by adding a required reviewers rule to a repository ruleset. This provides more granular control over code ownership than a single `CODEOWNERS` file, which is difficult to scale for large organizations or monorepos. For example, administrators can require QA reviewers on a release branch, security team members on the default branch, or product and design reviewers on feature branches, including setting the minimum number of required reviews per team. # https://github.com/github/releases/issues/6784 - | Developers can find and organize their repositories more efficiently using the new repositories dashboard at `/repos`. The dashboard displays a developer's most active repositories and allows them to create saved views for quick access to the repositories they use most. This gives developers a centralized page for tracking their work and streamlining their workflows. For details, see the [changelog](https://github.blog/changelog/2026-02-24-repository-dashboard-is-now-generally-available/). @@ -274,3 +271,7 @@ sections: # https://github.com/github/releases/issues/6651 - | Starting in 3.22, networking-related syscalls will be disabled by default in the pre-receive hook environment. For enhanced security, hook environments will be placed in dedicated network namespaces. You will be able to override the default setting by setting pre-receive-hook-networking to enabled. In many cases, push rulesets are an alternative for many pre-receive hooks. For more information, see [AUTOTITLE](/enterprise-server@3.22/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets#push-rulesets). + + errata: + - | + These release notes previously included a note about required reviewers rules for repository rulesets. The feature is not available in this release and will be included in a future release. [Updated: 2026-10-19] diff --git a/src/archives/middleware/archived-enterprise-versions.ts b/src/archives/middleware/archived-enterprise-versions.ts index cae98add1a4f..a96813a02b5d 100644 --- a/src/archives/middleware/archived-enterprise-versions.ts +++ b/src/archives/middleware/archived-enterprise-versions.ts @@ -74,6 +74,9 @@ export default async function archivedEnterpriseVersions( const { isArchived, requestedVersion } = isArchivedVersion(req) if (!isArchived || !requestedVersion) return next() + // trailingSlashes and handleRedirects 301 repeated slashes to the collapsed path, which returns here. + if (req.path.includes('//')) return next() + if (patterns.assetPaths.test(req.path)) return next() const redirectCode = pathLanguagePrefixed(req.path) ? 301 : 302 @@ -426,11 +429,6 @@ const archiveLanguagePrefixRegex = new RegExp(`^/(${Object.keys(allLanguages).jo // Identifies request paths that cannot resolve on upstream GitHub Pages archive repos. // Returning a reason lets callers log and skip the network request. function getEarlyNotFoundReason(reqPath: string, version: string): string | null { - // Double slashes never resolve, such as /about-2fa//index.html. - if (reqPath.includes('//')) { - return 'double-slash' - } - // Duplicated /developer/developer/ segments come from broken developer.github.com crawler URLs. if (reqPath.includes('/developer/developer/')) { return 'developer-developer' diff --git a/src/archives/tests/deprecated-enterprise-versions.ts b/src/archives/tests/deprecated-enterprise-versions.ts index ab9a668521ca..614cca0cef83 100644 --- a/src/archives/tests/deprecated-enterprise-versions.ts +++ b/src/archives/tests/deprecated-enterprise-versions.ts @@ -43,6 +43,16 @@ describe('enterprise deprecation', () => { ) }) + test('redirects double-slash paths in deprecated enterprise content to the collapsed path', async () => { + const res = await get('/en/enterprise-server@3.5//admin/overview') + expect(res.statusCode).toBe(301) + expect(res.headers.location).toBe('/en/enterprise-server@3.5/admin/overview') + + const res2 = await get('/en/enterprise/2.15/user//articles/about-branches') + expect(res2.statusCode).toBe(301) + expect(res2.headers.location).toBe('/en/enterprise/2.15/user/articles/about-branches') + }) + test('handles requests for deprecated Enterprise pages ( >=2.13 )', async () => { expect(enterpriseServerReleases.deprecated.includes('2.13')).toBe(true) const { $, res } = await getDOM('/en/enterprise/2.13/user/articles/about-branches') diff --git a/src/article-api/README.md b/src/article-api/README.md index b1741e09a82f..b5137f65d85e 100644 --- a/src/article-api/README.md +++ b/src/article-api/README.md @@ -28,7 +28,7 @@ The `/api/article` endpoints return information about a page by `pathname`. The pagelist (`/api/pagelist/:lang/:version`) returns only **canonical permalinks**. The article API (`/api/article`, `/api/article/body`, `/api/article/meta`) transparently follows redirects—so URLs that don't appear in the pagelist (such as `redirect_from` aliases or old paths) may still return content. -When the article API resolves a redirect through the redirect table, the JSON response includes a `redirectedFrom` field containing the normalized pathname that was looked up (after trailing-slash removal and other standard normalization, not the raw originally-requested pathname). This field is only set for redirect-table lookups; it is not set for the bare `/` to `/` language rewrite. This lets consumers detect that the URL they requested is not canonical. The `/api/article/body` endpoint returns plain text, so `redirectedFrom` is not included in its response. +When the article API resolves a redirect through the redirect table, the JSON response includes a `redirectedFrom` field containing the normalized pathname that was looked up (after trailing-slash removal and other standard normalization, not the raw originally-requested pathname). This field is set for redirect-table lookups and when a pathname without a language prefix, such as `/get-started/start-your-journey/hello-world`, resolves to its `/en` page. It is not set for the bare `/` to `/` language rewrite. This lets consumers detect that the URL they requested is not canonical. The `/api/article/body` endpoint returns plain text, so `redirectedFrom` is not included in its response. For autogenerated pages (REST, GraphQL, webhooks, landing pages, audit logs, etc), the Article API uses specialized transformers to convert the rendered content into markdown format. These transformers are located in `src/article-api/transformers/` and use an extensible architecture. diff --git a/src/article-api/middleware/validation.ts b/src/article-api/middleware/validation.ts index cfad75632cbe..cb89a7be74c5 100644 --- a/src/article-api/middleware/validation.ts +++ b/src/article-api/middleware/validation.ts @@ -4,6 +4,7 @@ import type { NextFunction, Response } from 'express' import { ExtendedRequest, Page } from '@/types' import { isArchivedVersionByPath } from '@/archives/lib/is-archived-version' import getRedirect from '@/redirects/lib/get-redirect' +import { pathLanguagePrefixed } from '@/languages/lib/languages-server' import { getVersionStringFromPath, getLangFromPath } from '@/frame/lib/path-utils' import nonEnterpriseDefaultVersion from '@/versions/lib/non-enterprise-default-version' import { allVersions } from '@/versions/lib/all-versions' @@ -96,6 +97,13 @@ export const pageValidationMiddleware = ( if (redirect) { req.pageinfo.redirectedFrom = pathname pathname = redirect + } else if (!pathLanguagePrefixed(pathname)) { + // The redirect table omits live permalinks, so add the language prefix as the site does. + const prefixedPathname = `/${req.context.currentLanguage}${pathname}` + if (prefixedPathname in req.context.pages) { + req.pageinfo.redirectedFrom = pathname + pathname = prefixedPathname + } } } } diff --git a/src/article-api/tests/pageinfo.ts b/src/article-api/tests/pageinfo.ts index 00d34d71be2c..1e6553864201 100644 --- a/src/article-api/tests/pageinfo.ts +++ b/src/article-api/tests/pageinfo.ts @@ -188,6 +188,24 @@ describe('pageinfo api', () => { } }) + test('language-less canonical pathnames', async () => { + { + const res = await get(makeURL('/get-started/start-your-journey/hello-world')) + expect(res.statusCode).toBe(200) + const meta = JSON.parse(res.body) as PageMetadata + expect(meta.title).toBe('Hello World') + expect(meta.redirectedFrom).toBe('/get-started/start-your-journey/hello-world') + } + { + const res = await get( + makeURL('/enterprise-cloud@latest/get-started/start-your-journey/hello-world'), + ) + expect(res.statusCode).toBe(200) + const meta = JSON.parse(res.body) as PageMetadata + expect(meta.title).toBe('Hello World') + } + }) + test('documentType for different page types', async () => { { const res = await get(makeURL('/en')) diff --git a/src/article-api/tests/validation.ts b/src/article-api/tests/validation.ts new file mode 100644 index 000000000000..4685d5b173fb --- /dev/null +++ b/src/article-api/tests/validation.ts @@ -0,0 +1,73 @@ +import { describe, expect, test, vi } from 'vitest' +import type { Response } from 'express' + +import { pageValidationMiddleware } from '@/article-api/middleware/validation' +import type { ExtendedRequestWithPageInfo } from '@/article-api/types' +import type { Context, Page } from '@/types' + +const helloWorld = { title: 'Hello World' } as unknown as Page +const helloWorldGhec = { title: 'Hello World GHEC' } as unknown as Page + +const pages: Record = { + '/en/get-started/start-your-journey/hello-world': helloWorld, + '/en/enterprise-cloud@latest/get-started/start-your-journey/hello-world': helloWorldGhec, +} + +function createRequest(pathname: string): ExtendedRequestWithPageInfo { + return { + context: { pages, redirects: {}, currentLanguage: 'en' } as unknown as Context, + pageinfo: { pathname, page: {} as Page }, + } as unknown as ExtendedRequestWithPageInfo +} + +function createResponse() { + const res = { + status: vi.fn(), + json: vi.fn(), + } + res.status.mockReturnValue(res) + res.json.mockReturnValue(res) + return res +} + +describe('pageValidationMiddleware', () => { + test('adds the current language to a language-less canonical pathname', () => { + const req = createRequest('/get-started/start-your-journey/hello-world') + const res = createResponse() + const next = vi.fn() + pageValidationMiddleware(req, res as unknown as Response, next) + expect(res.status).not.toHaveBeenCalled() + expect(next).toHaveBeenCalledOnce() + expect(req.pageinfo.page).toBe(helloWorld) + expect(req.pageinfo.pathname).toBe('/en/get-started/start-your-journey/hello-world') + expect(req.pageinfo.redirectedFrom).toBe('/get-started/start-your-journey/hello-world') + }) + + test('adds the current language to a language-less versioned pathname', () => { + const req = createRequest( + '/enterprise-cloud@latest/get-started/start-your-journey/hello-world/', + ) + const res = createResponse() + const next = vi.fn() + pageValidationMiddleware(req, res as unknown as Response, next) + expect(res.status).not.toHaveBeenCalled() + expect(next).toHaveBeenCalledOnce() + expect(req.pageinfo.page).toBe(helloWorldGhec) + expect(req.pageinfo.pathname).toBe( + '/en/enterprise-cloud@latest/get-started/start-your-journey/hello-world', + ) + expect(req.pageinfo.redirectedFrom).toBe( + '/enterprise-cloud@latest/get-started/start-your-journey/hello-world', + ) + }) + + test('returns 404 for a language-less pathname with no page', () => { + const req = createRequest('/never/heard/of') + const res = createResponse() + const next = vi.fn() + pageValidationMiddleware(req, res as unknown as Response, next) + expect(next).not.toHaveBeenCalled() + expect(res.status).toHaveBeenCalledWith(404) + expect(res.json).toHaveBeenCalledWith({ error: "No page found for '/never/heard/of'" }) + }) +}) diff --git a/src/content-linter/lib/linting-rules/code-annotation-comment-spacing.ts b/src/content-linter/lib/linting-rules/code-annotation-comment-spacing.ts index c985ab8a5dd4..02e162b12498 100644 --- a/src/content-linter/lib/linting-rules/code-annotation-comment-spacing.ts +++ b/src/content-linter/lib/linting-rules/code-annotation-comment-spacing.ts @@ -1,7 +1,21 @@ +import fs from 'fs' +import { load } from 'js-yaml' import { addError, filterTokens } from 'markdownlint-rule-helpers' import type { RuleParams, RuleErrorCallback, MarkdownToken } from '@/content-linter/types' +const languages = load(fs.readFileSync('data/code-languages.yml', 'utf8')) as Record< + string, + { comment?: string } +> + +// Match the annotate renderer, which only treats the language's own comment syntax as a comment. +const commentPrefixes: Record = { + number: '#', + slash: '//', + hyphen: '--', +} + export const codeAnnotationCommentSpacing = { names: ['GHD045', 'code-annotation-comment-spacing'], description: @@ -12,6 +26,10 @@ export const codeAnnotationCommentSpacing = { filterTokens(params, 'fence', (token: MarkdownToken) => { if (!token.info?.includes('annotate')) return + const lang = token.info.trim().split(/\s+/)[0] + const commentChar = commentPrefixes[languages[lang]?.comment ?? ''] + if (!commentChar) return + const content = token.content if (!content) return @@ -22,26 +40,9 @@ export const codeAnnotationCommentSpacing = { const trimmedLine = line.trim() if (!trimmedLine) continue - const commentPatterns: Record = { - '//': /^(\/\/)(.*)/, // JavaScript/TypeScript/Java/C# style comments - '#': /^(#)(.*)/, // Python/Ruby/Shell/YAML style comments - '--': /^(--)(.*)/, // SQL/Lua style comments - } - - let commentMatch: RegExpMatchArray | null = null - let commentChar: string | null = null - let restOfLine: string | null = null - - for (const [char, pattern] of Object.entries(commentPatterns)) { - if (trimmedLine.startsWith(char)) { - commentMatch = trimmedLine.match(pattern) - commentChar = char - restOfLine = commentMatch ? commentMatch[2] : '' - break - } - } + if (trimmedLine.startsWith(commentChar)) { + const restOfLine = trimmedLine.slice(commentChar.length) - if (commentMatch && restOfLine !== null && commentChar !== null) { // Treat shebang lines as executable directives, not code comments. if (trimmedLine.startsWith('#!')) { continue diff --git a/src/content-linter/tests/unit/code-annotation-comment-spacing.ts b/src/content-linter/tests/unit/code-annotation-comment-spacing.ts index 0d19056b3d45..ae7f766a241f 100644 --- a/src/content-linter/tests/unit/code-annotation-comment-spacing.ts +++ b/src/content-linter/tests/unit/code-annotation-comment-spacing.ts @@ -13,12 +13,16 @@ describe(codeAnnotationCommentSpacing.names.join(' - '), () => { '// This is a correct comment', 'const express = require("express");', '', - '# This is also correct', - 'const app = express();', - '', '//', 'const emptyCommentAbove = "fine";', + '```', '', + '```yaml copy annotate', + '# This is also correct', + 'name: test', + '```', + '', + '```sql copy annotate', '-- SQL comment with space', 'SELECT * FROM users;', '```', @@ -37,10 +41,14 @@ describe(codeAnnotationCommentSpacing.names.join(' - '), () => { '```javascript copy annotate', '//This should fail the content linter', 'const express = require("express");', + '```', '', + '```yaml copy annotate', '#This should also fail', - 'const app = express();', + 'name: test', + '```', '', + '```sql copy annotate', '--This should fail too', 'SELECT * FROM users;', '```', @@ -59,19 +67,19 @@ describe(codeAnnotationCommentSpacing.names.join(' - '), () => { insertText: '// This should fail the content linter', }) - expect(errors[1].lineNumber).toBe(8) + expect(errors[1].lineNumber).toBe(10) expect(errors[1].errorDetail).toContain("Comment must have exactly one space after '#'") expect(errors[1].fixInfo).toEqual({ - lineNumber: 8, + lineNumber: 10, editColumn: 1, deleteCount: 22, insertText: '# This should also fail', }) - expect(errors[2].lineNumber).toBe(11) + expect(errors[2].lineNumber).toBe(15) expect(errors[2].errorDetail).toContain("Comment must have exactly one space after '--'") expect(errors[2].fixInfo).toEqual({ - lineNumber: 11, + lineNumber: 15, editColumn: 1, deleteCount: 22, insertText: '-- This should fail too', @@ -86,10 +94,14 @@ describe(codeAnnotationCommentSpacing.names.join(' - '), () => { '```javascript copy annotate', '// This has too many spaces', 'const express = require("express");', + '```', '', + '```yaml copy annotate', '# This also has too many', - 'const app = express();', + 'name: test', + '```', '', + '```sql copy annotate', '-- This has way too many', 'SELECT * FROM users;', '```', @@ -110,23 +122,23 @@ describe(codeAnnotationCommentSpacing.names.join(' - '), () => { insertText: '// This has too many spaces', }) - expect(errors[1].lineNumber).toBe(8) + expect(errors[1].lineNumber).toBe(10) expect(errors[1].errorDetail).toContain( "Comment must have exactly one space after '#', found multiple spaces", ) expect(errors[1].fixInfo).toEqual({ - lineNumber: 8, + lineNumber: 10, editColumn: 1, deleteCount: 26, insertText: '# This also has too many', }) - expect(errors[2].lineNumber).toBe(11) + expect(errors[2].lineNumber).toBe(15) expect(errors[2].errorDetail).toContain( "Comment must have exactly one space after '--', found multiple spaces", ) expect(errors[2].fixInfo).toEqual({ - lineNumber: 11, + lineNumber: 15, editColumn: 1, deleteCount: 27, insertText: '-- This has way too many', @@ -142,11 +154,14 @@ describe(codeAnnotationCommentSpacing.names.join(' - '), () => { 'function test() {', ' //Missing space in indented comment', ' const x = 1;', - ' ', - ' # Too many spaces', - ' const y = 2;', '}', '```', + '', + '```yaml copy annotate', + 'jobs:', + ' # Too many spaces', + ' build:', + '```', ].join('\n') const result = await runRule(codeAnnotationCommentSpacing, { strings: { markdown } }) @@ -162,12 +177,12 @@ describe(codeAnnotationCommentSpacing.names.join(' - '), () => { insertText: ' // Missing space in indented comment', }) - expect(errors[1].lineNumber).toBe(9) + expect(errors[1].lineNumber).toBe(13) expect(errors[1].errorDetail).toContain( "Comment must have exactly one space after '#', found multiple spaces", ) expect(errors[1].fixInfo).toEqual({ - lineNumber: 9, + lineNumber: 13, editColumn: 1, deleteCount: 20, insertText: ' # Too many spaces', @@ -213,7 +228,7 @@ describe(codeAnnotationCommentSpacing.names.join(' - '), () => { expect(errors.length).toBe(0) }) - test('mixed comment types in same block', async () => { + test('only the comment syntax of the fence language is checked', async () => { const markdown = [ '---', 'layout: inline', @@ -221,19 +236,15 @@ describe(codeAnnotationCommentSpacing.names.join(' - '), () => { '```bash copy annotate', '#!/bin/bash', '#This shell comment needs space', - 'echo "Hello"', - '', - '// This JS-style comment is fine', - 'node script.ts', - '', - '--This SQL comment needs space', - 'psql -c "SELECT 1;"', + 'curl \\', + ' --silent \\', + ' https://example.com', '```', ].join('\n') const result = await runRule(codeAnnotationCommentSpacing, { strings: { markdown } }) const errors = result.markdown - expect(errors.length).toBe(2) + expect(errors.length).toBe(1) expect(errors[0].lineNumber).toBe(6) expect(errors[0].errorDetail).toContain("Comment must have exactly one space after '#'") @@ -243,14 +254,43 @@ describe(codeAnnotationCommentSpacing.names.join(' - '), () => { deleteCount: 31, insertText: '# This shell comment needs space', }) + }) - expect(errors[1].lineNumber).toBe(12) - expect(errors[1].errorDetail).toContain("Comment must have exactly one space after '--'") - expect(errors[1].fixInfo).toEqual({ - lineNumber: 12, - editColumn: 1, - deleteCount: 30, - insertText: '-- This SQL comment needs space', - }) + test('code that looks like a comment in another language is ignored', async () => { + const markdown = [ + '---', + 'layout: inline', + '---', + '```yaml copy annotate', + '# Install ARC', + '- run: |', + ' helm install arc \\', + ' --namespace arc-systems \\', + ' oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller', + '---', + '# Start a service', + 'services:', + ' postgres:', + ' options: >-', + ' --health-cmd pg_isready', + '```', + '', + '```javascript copy annotate', + '// Track a count', + 'class Counter {', + ' #count = 0', + '}', + '```', + '', + '```csharp copy annotate', + '// Group helpers', + '#region Helpers', + '#endregion', + '```', + ].join('\n') + + const result = await runRule(codeAnnotationCommentSpacing, { strings: { markdown } }) + const errors = result.markdown + expect(errors.length).toBe(0) }) }) diff --git a/src/content-pipelines/state/copilot-cli.sha b/src/content-pipelines/state/copilot-cli.sha index 03dc9837ecd4..8ab18a85c9e2 100644 --- a/src/content-pipelines/state/copilot-cli.sha +++ b/src/content-pipelines/state/copilot-cli.sha @@ -1 +1 @@ -b2f3bcbf5d7dde32293acd4aef8e4c0beb989b7c +8860daf23f911fa47684866555ce82856e79a14a diff --git a/src/content-render/liquid/octicon.ts b/src/content-render/liquid/octicon.ts index 81ed01f1109f..eddbf916da0e 100644 --- a/src/content-render/liquid/octicon.ts +++ b/src/content-render/liquid/octicon.ts @@ -1,7 +1,7 @@ import { TokenizationError, type TagToken } from 'liquidjs' import octicons from '@primer/octicons' -const OptionsSyntax = /([a-zA-Z-]+)="([\w\s-]+)"*/g +const OptionsSyntax = /([a-zA-Z-]+)\s*=\s*"([^"]*)"/g const Syntax = new RegExp(`"(?[a-zA-Z-]+)"(?(?:\\s${OptionsSyntax.source})*)`) const SyntaxHelp = 'Syntax Error in tag \'octicon\' - Valid syntax: octicon "" ' diff --git a/src/content-render/tests/octicon.ts b/src/content-render/tests/octicon.ts index 7c6387c46a49..5b581a323b03 100644 --- a/src/content-render/tests/octicon.ts +++ b/src/content-render/tests/octicon.ts @@ -65,6 +65,35 @@ describe('octicon tag', () => { expect(actual).not.toContain('aria-label="check icon"') }) + test('keeps punctuation in aria-label', async () => { + const parens = await renderContent( + '{% octicon "bell" aria-label="You have (no) unread notifications" %}', + ) + expect(parens).toContain('aria-label="You have (no) unread notifications"') + + const commas = await renderContent( + '{% octicon "zoom-in" aria-label="The zoom icon next to Month, Quarter, or Year" %}', + ) + expect(commas).toContain('aria-label="The zoom icon next to Month, Quarter, or Year"') + + const apostrophe = await renderContent( + `{% octicon "x" aria-label="The icon to remove someone's credit" %}`, + ) + expect(apostrophe).toContain('aria-label="The icon to remove someone's credit"') + }) + + test('allows spaces around = in options', async () => { + const actual = await renderContent('{% octicon "check" aria-label= "Supported" %}') + expect(actual).toContain('aria-label="Supported"') + expect(actual).not.toContain('aria-label="check icon"') + }) + + test('keeps options that follow an aria-label with punctuation', async () => { + const actual = await renderContent('{% octicon "check" aria-label="A, b" width="64" %}') + expect(actual).toContain('aria-label="A, b"') + expect(actual).toContain('width="64"') + }) + test('auto-generates aria-label even when other attributes are provided', async () => { const actual = await renderContent('{% octicon "filter" width="32" %}') expect(actual).toContain('[] = [] @@ -48,7 +50,8 @@ function resetPageParams() { scrollDirection = 1 scrollFlipCount = 0 maxScrollY = 0 - // Keep previousPath so browser-back referrers can fall back to the prior docs path. + pageStartTime = Date.now() + isFullPageLoad = false hoveredUrls = new Set() } @@ -103,7 +106,7 @@ export function sendEvent({ created: new Date().toISOString(), page_event_id: pageEventId, - referrer: getReferrer(document.referrer), + referrer: pageReferrer ?? document.referrer, title: document.title, href: location.href, hostname: location.hostname, @@ -184,19 +187,6 @@ function queueEvent(eventBody: Record) { eventQueue.push(eventBody) } -// Browser-back navigation can omit the internal referrer path, so previousPath fills it in. -function getReferrer(documentReferrer: string) { - if (!previousPath) return documentReferrer - try { - // URL rejects malformed referrers, so invalid values pass through unchanged. - const referrerUrl = new URL(documentReferrer) - if (!referrerUrl.pathname || referrerUrl.pathname === '/') { - return location.origin + previousPath - } - } catch {} - return documentReferrer -} - function getColorModePreference() { // HTML attributes expose the resolved mode and preserve auto preference without parsing cookies. const html = document.querySelector('html') @@ -256,7 +246,9 @@ function sendPage() { function sendExit() { if (sentExit) return sentExit = true - const { render, firstContentfulPaint, domInteractive, domComplete } = getPerformance() + const { render, firstContentfulPaint, domInteractive, domComplete } = isFullPageLoad + ? getPerformance() + : {} const clampedScrollLength = Math.min(maxScrollY, 1) @@ -266,7 +258,7 @@ function sendExit() { exit_first_paint: firstContentfulPaint, exit_dom_interactive: domInteractive, exit_dom_complete: domComplete, - exit_visit_duration: (Date.now() - startVisitTime) / 1000, + exit_visit_duration: (Date.now() - pageStartTime) / 1000, exit_scroll_length: clampedScrollLength, exit_scroll_flip: scrollFlipCount, }) @@ -288,13 +280,14 @@ function initPageAndExitEvent() { Router.events.on('routeChangeStart', async (url) => { // At routeChangeStart, location.pathname is prior and url is upcoming; query/hash keep one page event. - previousPath = location.pathname + const previousPath = location.pathname const newPath = url?.toString().split('?')[0].split('#')[0] const shouldSendEvents = newPath !== previousPath if (shouldSendEvents) { sendExit() await waitForPageReady() resetPageParams() + pageReferrer = location.origin + previousPath sendPage() } }) diff --git a/src/events/lib/schema.ts b/src/events/lib/schema.ts index 27b2253255c9..09c1ec953b51 100644 --- a/src/events/lib/schema.ts +++ b/src/events/lib/schema.ts @@ -41,7 +41,8 @@ const context = { referrer: { type: 'string', - description: 'The browser value of `document.referrer`.', + description: + 'The browser value of `document.referrer` on a full page load, or the previous docs URL after client-side navigation.', format: 'uri-reference', }, title: { @@ -258,26 +259,27 @@ const exit = { }, exit_render_duration: { type: 'number', - description: 'How long the server took to render the page content, in seconds.', + description: + 'How long the server took to render the page content, in seconds. Only sent for a full page load.', minimum: 0, }, exit_first_paint: { type: 'number', minimum: 0, description: - 'The duration until `first-contentful-paint`, in seconds. Informs CSS performance.', + 'The duration until `first-contentful-paint`, in seconds. Informs CSS performance. Only sent for a full page load.', }, exit_dom_interactive: { type: 'number', minimum: 0, description: - 'The duration until `PerformanceNavigationTiming.domInteractive`, in seconds. Informs JavaScript loading performance.', + 'The duration until `PerformanceNavigationTiming.domInteractive`, in seconds. Informs JavaScript loading performance. Only sent for a full page load.', }, exit_dom_complete: { type: 'number', minimum: 0, description: - 'The duration until `PerformanceNavigationTiming.domComplete`, in seconds. Informs JavaScript execution performance.', + 'The duration until `PerformanceNavigationTiming.domComplete`, in seconds. Informs JavaScript execution performance. Only sent for a full page load.', }, exit_visit_duration: { type: 'number', diff --git a/src/events/tests/page-exit-events.ts b/src/events/tests/page-exit-events.ts new file mode 100644 index 000000000000..99af6aa595d5 --- /dev/null +++ b/src/events/tests/page-exit-events.ts @@ -0,0 +1,158 @@ +import type { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +import { EventType } from '../types' + +type SentEvent = { type: string; context: Record; [key: string]: unknown } + +const sentEvents = vi.hoisted(() => [] as SentEvent[]) + +vi.mock('next/router', async () => { + const { EventEmitter } = await import('node:events') + return { Router: { events: new EventEmitter() } } +}) + +vi.mock('@/events/components/hydro-analytics', () => ({ + getOctoClientId: () => undefined, + sendHydroAnalyticsEvent: (body: SentEvent) => sentEvents.push(body), +})) + +vi.mock('@/frame/components/lib/cookies', () => ({ + default: { get: () => undefined, set: () => undefined }, +})) + +const ORIGIN = 'https://docs.github.com' + +let mutationCallback: (mutations: unknown[]) => void +const location = { origin: ORIGIN, hostname: 'docs.github.com', pathname: '', search: '', hash: '' } + +function stubBrowser(pathname: string, referrer: string) { + const noop = () => undefined + location.pathname = pathname + vi.stubGlobal('location', location) + vi.stubGlobal('window', { + addEventListener: noop, + screen: { width: 1280, height: 800 }, + devicePixelRatio: 1, + }) + vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0', language: 'en', sendBeacon: noop }) + vi.stubGlobal('document', { + referrer, + title: 'GitHub Docs', + visibilityState: 'visible', + addEventListener: noop, + querySelector: () => null, + getElementsByTagName: () => [{}], + documentElement: { clientWidth: 1280, clientHeight: 800, addEventListener: noop }, + }) + vi.spyOn(performance, 'getEntriesByType').mockImplementation( + (type: string) => + (type === 'navigation' + ? [{ requestStart: 100, responseEnd: 400, domInteractive: 800, domComplete: 1200 }] + : [{ name: 'first-contentful-paint', startTime: 600 }]) as ReturnType< + typeof performance.getEntriesByType + >, + ) + vi.stubGlobal( + 'MutationObserver', + class { + constructor(callback: (mutations: unknown[]) => void) { + mutationCallback = callback + } + observe() {} + disconnect() {} + }, + ) +} + +async function startSession(pathname: string, referrer = '') { + stubBrowser(pathname, referrer) + const { initializeEvents } = await import('@/events/components/events') + const { Router } = await import('next/router') + initializeEvents() + + return async function navigate(nextPath: string) { + Router.events.emit('routeChangeStart', nextPath, { shallow: false }) + location.pathname = nextPath + Router.events.emit('routeChangeComplete', nextPath, { shallow: false }) + mutationCallback([{ target: { nodeName: 'META' }, addedNodes: [], removedNodes: [] }]) + await vi.advanceTimersByTimeAsync(0) + } +} + +function eventsOfType(type: EventType) { + return sentEvents.filter((event) => event.type === type) +} + +describe('page and exit events across client-side navigation', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.resetModules() + sentEvents.length = 0 + }) + + afterEach(async () => { + // The next/router mock survives resetModules, so drop the listeners of earlier events modules. + const { Router } = await import('next/router') + ;(Router.events as unknown as EventEmitter).removeAllListeners() + vi.useRealTimers() + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + test('exit_visit_duration measures time on the current page', async () => { + const navigate = await startSession('/en/get-started') + await vi.advanceTimersByTimeAsync(60_000) + await navigate('/en/actions') + await vi.advanceTimersByTimeAsync(5_000) + await navigate('/en/pages') + + const exits = eventsOfType(EventType.exit) + expect(exits.map((event) => event.context.path)).toEqual(['/en/get-started', '/en/actions']) + expect(exits.map((event) => event.exit_visit_duration)).toEqual([60, 5]) + }) + + test('referrer is the previous docs URL after client-side navigation', async () => { + const navigate = await startSession('/en/get-started', `${ORIGIN}/en/search`) + await navigate('/en/actions') + await navigate('/en/pages') + + expect(eventsOfType(EventType.page).map((event) => event.context.referrer)).toEqual([ + `${ORIGIN}/en/search`, + `${ORIGIN}/en/get-started`, + `${ORIGIN}/en/actions`, + ]) + expect(eventsOfType(EventType.exit).map((event) => event.context.referrer)).toEqual([ + `${ORIGIN}/en/search`, + `${ORIGIN}/en/get-started`, + ]) + }) + + test('referrer fills in the previous docs URL when the landing page had none', async () => { + const navigate = await startSession('/en/get-started') + await navigate('/en/actions') + + expect(eventsOfType(EventType.page).map((event) => event.context.referrer)).toEqual([ + '', + `${ORIGIN}/en/get-started`, + ]) + }) + + test('navigation timings appear only on the full page load exit', async () => { + const navigate = await startSession('/en/get-started') + await navigate('/en/actions') + await navigate('/en/pages') + + const [landingExit, softExit] = eventsOfType(EventType.exit) + expect(landingExit).toMatchObject({ + exit_render_duration: 0.3, + exit_first_paint: 0.6, + exit_dom_interactive: 0.8, + exit_dom_complete: 1.2, + }) + expect(softExit.exit_render_duration).toBeUndefined() + expect(softExit.exit_first_paint).toBeUndefined() + expect(softExit.exit_dom_interactive).toBeUndefined() + expect(softExit.exit_dom_complete).toBeUndefined() + }) +}) diff --git a/src/fixtures/tests/playwright-rendering.spec.ts b/src/fixtures/tests/playwright-rendering.spec.ts index ce84515d24b4..1dfad47d6ae0 100644 --- a/src/fixtures/tests/playwright-rendering.spec.ts +++ b/src/fixtures/tests/playwright-rendering.spec.ts @@ -1481,6 +1481,39 @@ test.describe('test nav at different viewports', () => { await expect(page.locator('#main-content')).toBeVisible() }) + test('collapsed desktop rail stays collapsed after reload', async ({ page }) => { + await page.setViewportSize({ width: 1400, height: 700 }) + await page.goto('/get-started/foo/bar') + await page.getByTestId('sidebar-collapse-toggle').click() + await expect(page.getByTestId('sidebar')).toHaveCount(0) + + // The server renders the rail open, so the client must apply the saved cookie. + await page.reload() + await expect(page.getByTestId('sidebar')).toHaveCount(0) + }) + + test('expanded sidebar category stays open after reload', async ({ page }) => { + await page.setViewportSize({ width: 1400, height: 700 }) + await page.goto('/get-started') + const sidebar = page.getByTestId('product-sidebar') + const helloWorld = sidebar.getByRole('link', { name: 'Hello World' }) + await sidebar.getByText('Start your journey').click() + await expect(helloWorld).toBeVisible() + + // The server renders categories collapsed, so the client must apply the saved cookie. + await page.reload() + await expect(helloWorld).toBeVisible() + + // The first toggle after reload must merge into the saved cookie, not replace it. + await sidebar.getByText('Foo', { exact: true }).click() + await expect(sidebar.getByRole('link', { name: 'Bar', exact: true })).toBeVisible() + await expect(helloWorld).toBeVisible() + + await page.reload() + await expect(sidebar.getByRole('link', { name: 'Bar', exact: true })).toBeVisible() + await expect(helloWorld).toBeVisible() + }) + test('resizing from mobile to desktop closes the inline nav', async ({ page }) => { // Below lg 1012px, the inline mobile nav lives in the secondary bar. await page.setViewportSize({ @@ -2483,6 +2516,34 @@ test.describe('Docs 2026 in-article navigation', () => { await page.mouse.wheel(0, 2000) await expect(trigger).not.toContainText('Macintosh') }) + + // On short viewports 15% of the height sits above the anchor scroll margin. + test('a menu jump on a short viewport activates that section below the sticky bars', async ({ + page, + }) => { + await page.setViewportSize({ width: 1366, height: 640 }) + await page.goto('/get-started/foo/anchor-scrolling') + await turnOffExperimentsInPage(page) + + const subBar = page.getByTestId('overview-subbar') + const trigger = subBar.getByRole('button') + await trigger.click() + await page + .getByTestId('overview-menu') + .getByRole('link', { name: 'Target heading', exact: true }) + .click() + await expect(page).toHaveURL(/#target-heading$/) + await expect(trigger).toContainText('Target heading') + + const subBarBox = (await subBar.boundingBox())! + const headingBox = (await page.locator('#target-heading').boundingBox())! + expect(headingBox.y).toBeGreaterThanOrEqual(subBarBox.y + subBarBox.height - 2) + + await trigger.click() + await expect( + page.getByTestId('overview-menu').getByRole('link', { name: 'Target heading', exact: true }), + ).toHaveAttribute('aria-current', 'location') + }) }) test.describe('CookBookFilter component', () => { diff --git a/src/frame/components/DefaultLayout.tsx b/src/frame/components/DefaultLayout.tsx index f8e7f6615d3c..526a2bd2f02f 100644 --- a/src/frame/components/DefaultLayout.tsx +++ b/src/frame/components/DefaultLayout.tsx @@ -221,7 +221,7 @@ export const DefaultLayout = (props: Props) => { > Skip to main content - +
diff --git a/src/frame/components/context/MainContext.tsx b/src/frame/components/context/MainContext.tsx index 41323f80ba5f..67487ab94507 100644 --- a/src/frame/components/context/MainContext.tsx +++ b/src/frame/components/context/MainContext.tsx @@ -5,7 +5,6 @@ import type { Response } from 'express' import type { BreadcrumbT } from '@/frame/components/page-header/Breadcrumbs' import type { FeatureFlags } from '@/frame/components/hooks/useFeatureFlags' import type { ExtendedRequest, Permalink, SidebarLink } from '@/types' -import { SIDEBAR_EXPANDED_COOKIE_NAME, SIDEBAR_COLLAPSED_COOKIE_NAME } from '@/frame/lib/constants' export type ProductT = { external: boolean @@ -126,14 +125,6 @@ export type MainContextT = { } | null relativePath?: string | null sidebarTree?: ProductTreeNode | null - // Per-category expand/collapse overrides for the doc-tree sidebar, read from the - // sidebar_expanded cookie during SSR so the tree renders in its persisted state - // on first paint (no post-mount flash). Keyed by locale-prefixed href. - sidebarExpanded?: Record | null - // Whether the desktop doc-tree rail is collapsed, read from the sidebar_collapsed - // cookie during SSR so the rail renders in its persisted state on first paint - // (no flash of the open sidebar before it collapses post-mount). - sidebarCollapsed?: boolean status: number xHost?: string } @@ -172,18 +163,6 @@ export function addUINamespaces(req: ExtendedRequest, ui: UIStrings, namespaces: } } -// Guard cookie parsing so a malformed or absent sidebar_expanded value degrades to no overrides. -function parseSidebarExpandedCookie(req: ExtendedRequest): Record { - const raw = req.cookies?.[SIDEBAR_EXPANDED_COOKIE_NAME] - if (!raw) return {} - try { - const parsed = JSON.parse(raw) - return parsed && typeof parsed === 'object' ? (parsed as Record) : {} - } catch { - return {} - } -} - // Translations add unused ms.* properties such as ms.openlocfilehash and // ms.sourcegitcommit, so remove them before building UI namespaces. export const getMainContext = async ( @@ -296,10 +275,6 @@ export const getMainContext = async ( relativePath: context.page?.relativePath || null, // Sidebar and REST pages need the minimal product tree. sidebarTree: (includeSidebarTree && context.sidebarTree) || null, - sidebarExpanded: includeSidebarTree ? parseSidebarExpandedCookie(req) : null, - sidebarCollapsed: includeSidebarTree - ? req.cookies?.[SIDEBAR_COLLAPSED_COOKIE_NAME] === 'true' - : false, status: res.statusCode, xHost: req.get('x-host') || '', } diff --git a/src/frame/components/hooks/useIsHydrating.ts b/src/frame/components/hooks/useIsHydrating.ts new file mode 100644 index 000000000000..e5597ffd3748 --- /dev/null +++ b/src/frame/components/hooks/useIsHydrating.ts @@ -0,0 +1,12 @@ +import { useSyncExternalStore } from 'react' + +const subscribe = () => () => {} +const getClientSnapshot = () => false +const getServerSnapshot = () => true + +// True on the server and while React hydrates server HTML, false for client renders after +// that. Hydrated components flip to false together in one re-render right after hydration +// commits, so components mounted by client-side navigation start at false. +export function useIsHydrating(): boolean { + return useSyncExternalStore(subscribe, getClientSnapshot, getServerSnapshot) +} diff --git a/src/frame/components/lib/add-feature-param-to-href.ts b/src/frame/components/lib/add-feature-param-to-href.ts new file mode 100644 index 000000000000..3f28617a9553 --- /dev/null +++ b/src/frame/components/lib/add-feature-param-to-href.ts @@ -0,0 +1,15 @@ +// Carries the page's `feature` query param over to a link while keeping the link's own query and fragment. +export function addFeatureParamToHref(href: string, search: string): string { + const feature = new URLSearchParams(search).get('feature') + // In-page fragment links never reach the server, so they gain nothing from the param. + if (!feature || !href || href.startsWith('#')) return href + + const hashIndex = href.indexOf('#') + const beforeHash = hashIndex === -1 ? href : href.slice(0, hashIndex) + const hash = hashIndex === -1 ? '' : href.slice(hashIndex) + const queryIndex = beforeHash.indexOf('?') + const path = queryIndex === -1 ? beforeHash : beforeHash.slice(0, queryIndex) + const params = new URLSearchParams(queryIndex === -1 ? '' : beforeHash.slice(queryIndex + 1)) + params.set('feature', feature) + return `${path}?${params.toString()}${hash}` +} diff --git a/src/frame/components/sidebar/SidebarCollapseContext.tsx b/src/frame/components/sidebar/SidebarCollapseContext.tsx index acaa7b4ac766..206250f5ce76 100644 --- a/src/frame/components/sidebar/SidebarCollapseContext.tsx +++ b/src/frame/components/sidebar/SidebarCollapseContext.tsx @@ -3,14 +3,16 @@ import type { ReactNode } from 'react' import { useRouter } from 'next/router' import Cookies from '@/frame/components/lib/cookies' +import { useIsHydrating } from '@/frame/components/hooks/useIsHydrating' import { SIDEBAR_COLLAPSED_COOKIE_NAME } from '@/frame/lib/constants' // Keep two sidebar state channels separate. collapsed persists in a cookie and // syncs the desktop toggle with the rail layout, mirroring // src/landings/components/useSidebarExpandState.tsx. mobileNavOpen never persists; -// the mobile trigger reads it for inline nav expansion. getMainContext reads the -// cookie server-side and passes initialCollapsed, so server markup and hydration -// match. Without an initial value, the client falls back to the SSR-safe cookie helper. +// the mobile trigger reads it for inline nav expansion. Page HTML is publicly cached +// and does not vary on Cookie, so the server and hydration render the rail open. The +// cookie applies in the re-render right after hydration, and client-side mounts read it +// on their first render. function readCollapsed(): boolean { try { @@ -41,30 +43,21 @@ type SidebarCollapseContextValue = { const SidebarCollapseContext = createContext(null) -export function SidebarCollapseProvider({ - children, - initialCollapsed, -}: { - children: ReactNode - initialCollapsed?: boolean -}) { +export function SidebarCollapseProvider({ children }: { children: ReactNode }) { const { asPath } = useRouter() - // Prefer the server-read cookie value so server markup and hydration match. - const [collapsed, setCollapsedState] = useState(() => initialCollapsed ?? readCollapsed()) + const hydrating = useIsHydrating() + const saved = useMemo(() => !hydrating && readCollapsed(), [hydrating]) + // Stays null until the reader toggles, so the saved state shows after hydration. + const [chosen, setChosen] = useState(null) + const collapsed = chosen ?? saved const [mobileNavOpen, setMobileNavOpen] = useState(false) const setCollapsed = useCallback((next: boolean) => { - setCollapsedState(next) + setChosen(next) persistCollapsed(next) }, []) - const toggleCollapsed = useCallback(() => { - setCollapsedState((prev) => { - const next = !prev - persistCollapsed(next) - return next - }) - }, []) + const toggleCollapsed = useCallback(() => setCollapsed(!collapsed), [collapsed, setCollapsed]) const toggleMobileNav = useCallback(() => setMobileNavOpen((prev) => !prev), []) const closeMobileNav = useCallback(() => setMobileNavOpen(false), []) diff --git a/src/frame/components/ui/MarkdownContent/UnrenderedMarkdownContent.tsx b/src/frame/components/ui/MarkdownContent/UnrenderedMarkdownContent.tsx index 03838824c647..ff274d08bfec 100644 --- a/src/frame/components/ui/MarkdownContent/UnrenderedMarkdownContent.tsx +++ b/src/frame/components/ui/MarkdownContent/UnrenderedMarkdownContent.tsx @@ -11,6 +11,7 @@ import { useTranslation } from '@/languages/components/useTranslation' import useCopyClipboard from '@/rest/components/useClipboard' import { EventType } from '@/events/types' import { sendEvent } from '@/events/components/events' +import { addFeatureParamToHref } from '@/frame/components/lib/add-feature-param-to-href' import styles from './MarkdownContent.module.scss' @@ -94,22 +95,9 @@ export const UnrenderedMarkdownContent = ({ // Custom anchors preserve feature query params and can force links into new tabs. components.a = ({ ...props }) => { - let href = props.href || '' - let existingAnchorParams = '' - if (includeQueryParams) { - if (href.includes('?')) { - href = href.split('?')[0] - existingAnchorParams = href.split('?')[1] - } - const existingURLParams = new URLSearchParams(window.location.search) - const newParams = new URLSearchParams() - if (existingURLParams.get('feature')) { - newParams.set('feature', existingURLParams.get('feature') || '') - } - if (newParams.toString()) { - href = `${href}?${existingAnchorParams}&${newParams.toString()}` - } - } + const href = includeQueryParams + ? addFeatureParamToHref(props.href || '', window.location.search) + : props.href || '' return ( window.innerHeight * 0.15 + // Floor the 15% band 1px past the :target scroll margin so short viewports activate a jumped-to heading. + const bandThreshold = () => { + const target = document.querySelector(':target') + const margin = target ? parseFloat(getComputedStyle(target).scrollMarginTop) || 0 : 0 + return Math.max(window.innerHeight * 0.15, margin + 1) + } // Actual scroll positions keep upward scrolling tied to the previous heading or Overview. const computeActive = () => { @@ -80,6 +84,8 @@ export function ActiveSectionProvider({ children }: { children: ReactNode }) { for (const { el } of pairs) observer.observe(el) window.addEventListener('scroll', scheduleUpdate, { passive: true }) window.addEventListener('resize', scheduleUpdate) + // A hash change can move :target and its threshold without any scroll or intersection change. + window.addEventListener('hashchange', scheduleUpdate) computeActive() return () => { @@ -87,6 +93,7 @@ export function ActiveSectionProvider({ children }: { children: ReactNode }) { observer.disconnect() window.removeEventListener('scroll', scheduleUpdate) window.removeEventListener('resize', scheduleUpdate) + window.removeEventListener('hashchange', scheduleUpdate) } // Re-run when platform or tool selection changes which headings are visible. }, [miniTocItems, selectedPlatform, tool]) diff --git a/src/frame/stylesheets/scroll-top.scss b/src/frame/stylesheets/scroll-top.scss index f7c3f93d1a1e..210108e2fee2 100644 --- a/src/frame/stylesheets/scroll-top.scss +++ b/src/frame/stylesheets/scroll-top.scss @@ -1,5 +1,3 @@ -@import "src/frame/stylesheets/breakpoint-xxl.scss"; - // Brand SubdomainNavBar height uses rem, so the header portion of sticky offsets must use rem too. // Brand sets --base-size-64 at 4rem and subtracts --base-size-8, 0.5rem, below its medium // breakpoint of 47.99rem. A px value or px breakpoint drifts when readers change the browser @@ -13,13 +11,11 @@ } // https://tetralogical.com/blog/2023/06/08/focus-in-view/ - +// Land anchor targets below every sticky bar. DefaultLayout.module.scss sets --docs-sticky-stack +// on the content column, including the In this article sub-bar when it shows. :target { - scroll-margin-top: calc(var(--docs-header-height) + 40px) !important; - - @include breakpoint-xxl { - scroll-margin-top: calc( - var(--docs-header-height) + 44px - ) !important; // Include the secondary bar below the header. - } + scroll-margin-top: var( + --docs-sticky-stack, + calc(var(--docs-header-height) + 45px) + ) !important; } diff --git a/src/frame/tests/add-feature-param-to-href.ts b/src/frame/tests/add-feature-param-to-href.ts new file mode 100644 index 000000000000..a0dec59cdbd2 --- /dev/null +++ b/src/frame/tests/add-feature-param-to-href.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from 'vitest' + +import { addFeatureParamToHref } from '@/frame/components/lib/add-feature-param-to-href' + +const restLink = + 'https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#create-a-repository' + +describe('addFeatureParamToHref', () => { + test('keeps query and fragment when there is no feature param', () => { + expect(addFeatureParamToHref(restLink, '')).toBe(restLink) + expect(addFeatureParamToHref(restLink, '?foo=bar')).toBe(restLink) + }) + + test('adds feature param after the existing query and before the fragment', () => { + expect(addFeatureParamToHref(restLink, '?feature=foo')).toBe( + 'https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28&feature=foo#create-a-repository', + ) + }) + + test('adds feature param before the fragment when there is no query', () => { + expect(addFeatureParamToHref('https://docs.github.com/en/foo#section', '?feature=foo')).toBe( + 'https://docs.github.com/en/foo?feature=foo#section', + ) + }) + + test('adds feature param to a plain link', () => { + expect(addFeatureParamToHref('https://docs.github.com/en/foo', '?feature=foo')).toBe( + 'https://docs.github.com/en/foo?feature=foo', + ) + }) + + test('keeps relative links relative', () => { + expect(addFeatureParamToHref('/en/foo?tool=cli', '?feature=foo')).toBe( + '/en/foo?tool=cli&feature=foo', + ) + }) + + test('replaces an existing feature param instead of duplicating it', () => { + expect( + addFeatureParamToHref('https://docs.github.com/en/foo?feature=old&tool=cli', '?feature=foo'), + ).toBe('https://docs.github.com/en/foo?feature=foo&tool=cli') + }) + + test('leaves in-page fragment links unchanged', () => { + expect(addFeatureParamToHref('#section', '?feature=foo')).toBe('#section') + }) + + test('leaves an empty href empty', () => { + expect(addFeatureParamToHref('', '?feature=foo')).toBe('') + }) +}) diff --git a/src/frame/tests/main-context.test.ts b/src/frame/tests/main-context.test.ts new file mode 100644 index 000000000000..844045213eb3 --- /dev/null +++ b/src/frame/tests/main-context.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from 'vitest' +import type { Response } from 'express' + +import { getMainContext } from '@/frame/components/context/MainContext' +import type { ExtendedRequest } from '@/types' + +const UI_NAMESPACES = [ + 'alerts', + 'header', + 'search', + 'old_search', + 'survey', + 'toc', + 'meta', + 'scroll_button', + 'pages', + 'picker', + 'footer', + 'contribution_cta', + 'support', + 'rest', + 'cookbook_landing', + 'code_tabs', +] + +function createRequest(cookies: Record): ExtendedRequest { + return { + cookies, + protocol: 'https', + hostname: 'docs.github.com', + originalUrl: '/en/get-started/foo/bar', + get: () => undefined, + context: { + site: { data: { ui: Object.fromEntries(UI_NAMESPACES.map((ns) => [ns, {}])) } }, + page: { documentType: 'article', title: 'Bar', permalinks: [] }, + sidebarTree: { title: 'Get started', href: '/en/get-started', childPages: [] }, + enterpriseServerReleases: { releasesWithOldestDeprecationDate: [], releaseCandidate: null }, + enterpriseServerVersions: [], + allVersions: {}, + currentVersion: 'free-pro-team@latest', + currentPathWithoutLanguage: '/get-started/foo/bar', + nonEnterpriseDefaultVersion: 'free-pro-team@latest', + }, + } as unknown as ExtendedRequest +} + +const res = { statusCode: 200 } as Response + +describe('getMainContext', () => { + // Page HTML and _next/data are publicly CDN-cached without varying on Cookie. + test('does not depend on per-user sidebar cookies', async () => { + const withCookies = await getMainContext( + createRequest({ + sidebar_expanded: JSON.stringify({ '/en/get-started': false }), + sidebar_collapsed: 'true', + }), + res, + ) + const withoutCookies = await getMainContext(createRequest({}), res) + + expect(withCookies).not.toHaveProperty('sidebarExpanded') + expect(withCookies).not.toHaveProperty('sidebarCollapsed') + expect(withCookies).toEqual(withoutCookies) + }) +}) diff --git a/src/frame/tests/sidebar-state-ssr.test.ts b/src/frame/tests/sidebar-state-ssr.test.ts new file mode 100644 index 000000000000..7acca7ebff94 --- /dev/null +++ b/src/frame/tests/sidebar-state-ssr.test.ts @@ -0,0 +1,55 @@ +import { createElement } from 'react' +import { renderToString } from 'react-dom/server' +import { describe, expect, test, vi } from 'vitest' + +import { + SidebarCollapseProvider, + useSidebarCollapsed, +} from '@/frame/components/sidebar/SidebarCollapseContext' +import { + SidebarExpandStateProvider, + useSidebarExpandState, +} from '@/landings/components/useSidebarExpandState' +import { SIDEBAR_COLLAPSED_COOKIE_NAME, SIDEBAR_EXPANDED_COOKIE_NAME } from '@/frame/lib/constants' + +// Serve saved state so the tests prove server markup ignores cookies even when readable. +vi.mock('@/frame/components/lib/cookies', () => ({ + default: { + get: (name: string) => + ({ + [SIDEBAR_COLLAPSED_COOKIE_NAME]: 'true', + [SIDEBAR_EXPANDED_COOKIE_NAME]: JSON.stringify({ '/en/saved': true }), + })[name], + set: () => undefined, + }, +})) + +vi.mock('next/router', () => ({ + useRouter: () => ({ asPath: '/en/saved' }), +})) + +function ExpandedProbe() { + const [expanded] = useSidebarExpandState('/en/saved', false) + return createElement('span', null, `expanded:${expanded}`) +} + +function CollapsedProbe() { + const { collapsed } = useSidebarCollapsed() + return createElement('span', null, `collapsed:${collapsed}`) +} + +describe('sidebar state server rendering', () => { + test('expand state renders the default tree despite a saved cookie', () => { + const html = renderToString( + createElement(SidebarExpandStateProvider, null, createElement(ExpandedProbe)), + ) + expect(html).toContain('expanded:false') + }) + + test('collapse state renders the rail open despite a saved cookie', () => { + const html = renderToString( + createElement(SidebarCollapseProvider, null, createElement(CollapsedProbe)), + ) + expect(html).toContain('collapsed:false') + }) +}) diff --git a/src/landings/components/SidebarProduct.tsx b/src/landings/components/SidebarProduct.tsx index 6480bcb99be4..c97d05be3cae 100644 --- a/src/landings/components/SidebarProduct.tsx +++ b/src/landings/components/SidebarProduct.tsx @@ -17,6 +17,7 @@ import { ProductTreeNode, useMainContext } from '@/frame/components/context/Main import { useAutomatedPageContextOptional } from '@/automated-pipelines/components/AutomatedPageContext' import { nonAutomatedRestPaths } from '@/rest/lib/config' import { usePrefetchOnInteraction } from '@/frame/components/lib/prefetch' +import { useIsHydrating } from '@/frame/components/hooks/useIsHydrating' import { SidebarExpandStateProvider, useSidebarExpandState } from './useSidebarExpandState' import { flattenDescendants, MAX_NAVLIST_LEVEL } from './sidebar-navlist-depth' @@ -121,7 +122,6 @@ export const SidebarProduct = () => { currentProduct, // The sidebar only needs short titles, so MainContext supplies the compressed tree. sidebarTree, - sidebarExpanded, } = useMainContext() const isRestPage = currentProduct && currentProduct.id === 'rest' @@ -147,6 +147,7 @@ export const SidebarProduct = () => { ) const restNavValue = useMemo(() => ({ asPath, query }), [asPath, query]) const rootRef = useRef(null) + const hydrating = useIsHydrating() useEffect(() => { // Failed navigations clear pendingHref; cancellations keep the newer click highlighted. @@ -158,6 +159,8 @@ export const SidebarProduct = () => { }, [router.events]) useEffect(() => { + // Wait for the saved expand state, which SidebarExpandStateProvider applies after hydration. + if (hydrating) return // Article filter query params are shallow same-page updates; the grid manages their scroll. if (/[?&]articles-(filter|category|page)=/.test(router.asPath)) return // Brand expands every active ancestor, so scroll to the aria-current page item. @@ -174,7 +177,7 @@ export const SidebarProduct = () => { const delta = activeRect.top - containerRect.top - (container.clientHeight - activeRect.height) / 2 container.scrollBy({ top: delta, behavior: 'instant' }) - }, []) + }, [hydrating]) if (!sidebarTree) { return null @@ -225,7 +228,7 @@ export const SidebarProduct = () => { return (
- + {isRestPage ? restSection() : productSection()} diff --git a/src/landings/components/useSidebarExpandState.tsx b/src/landings/components/useSidebarExpandState.tsx index d08b29c27838..850d368ef657 100644 --- a/src/landings/components/useSidebarExpandState.tsx +++ b/src/landings/components/useSidebarExpandState.tsx @@ -2,6 +2,7 @@ import { createContext, useCallback, useContext, useMemo, useState } from 'react import type { ReactNode } from 'react' import Cookies from '@/frame/components/lib/cookies' +import { useIsHydrating } from '@/frame/components/hooks/useIsHydrating' import { SIDEBAR_EXPANDED_COOKIE_NAME } from '@/frame/lib/constants' // Persists the docs sidebar's expand/collapse state across navigations. The tree @@ -15,10 +16,10 @@ import { SIDEBAR_EXPANDED_COOKIE_NAME } from '@/frame/lib/constants' // aria-current chain for uncontrolled items, a controlled item must fold that in // itself, which is what the onActiveChain fallback does here. // -// Server-side rendering reads the cookie in getMainContext and passes it as initial, -// so the first server and client render match with no post-mount flash. Without -// initial, for example outside the server-side data path, it falls back to the -// SSR-safe cookie lib on the client. +// Page HTML and _next/data are publicly cached and do not vary on Cookie, so the +// server and hydration render the default state. The cookie applies in the re-render +// right after hydration, and client-side mounts read it on their first render. +// SidebarProduct waits for the same useIsHydrating flip to center the saved tree. type ExpandedStore = Record @@ -29,12 +30,16 @@ type ExpandStateContextValue = { const ExpandStateContext = createContext(null) +// A shared empty store keeps the context value stable when nothing is saved. +const EMPTY_STORE: ExpandedStore = {} + function readStore(): ExpandedStore { try { const raw = Cookies.get(SIDEBAR_EXPANDED_COOKIE_NAME) - return raw ? (JSON.parse(raw) as ExpandedStore) : {} + const parsed = raw ? JSON.parse(raw) : null + return parsed && typeof parsed === 'object' ? (parsed as ExpandedStore) : EMPTY_STORE } catch { - return {} + return EMPTY_STORE } } @@ -46,19 +51,16 @@ function persistStore(store: ExpandedStore) { } } -export function SidebarExpandStateProvider({ - children, - initial, -}: { - children: ReactNode - initial?: ExpandedStore | null -}) { - // Seed from the server-read cookie, or read the cookie client-side when initial is absent. - const [store, setStore] = useState(() => initial ?? readStore()) +export function SidebarExpandStateProvider({ children }: { children: ReactNode }) { + const hydrating = useIsHydrating() + const saved = useMemo(() => (hydrating ? EMPTY_STORE : readStore()), [hydrating]) + // Stays null until the reader toggles a category, so the saved store shows after hydration. + const [edited, setEdited] = useState(null) + const store = edited ?? saved const setExpanded = useCallback((key: string, expanded: boolean) => { - setStore((prev) => { - const next = { ...prev, [key]: expanded } + setEdited((prev) => { + const next = { ...(prev ?? readStore()), [key]: expanded } persistStore(next) return next }) diff --git a/src/languages/lib/correct-translation-content.ts b/src/languages/lib/correct-translation-content.ts index 09e63a409af4..59d26a54f161 100644 --- a/src/languages/lib/correct-translation-content.ts +++ b/src/languages/lib/correct-translation-content.ts @@ -698,6 +698,11 @@ export function correctTranslatedContentStrings( 'do {% endif %}{% if ideEntry.versionType == "extension" %}{% data variables.copilot.copilot_extension %} IDE.', 'do {% if ideEntry.versionType == "extension" %}{% data variables.copilot.copilot_extension %}{% endif %} IDE.', ) + + content = content.replaceAll( + '{% ifversion ai-powered-security-detections %} e AI Scan funcionam em solicitações de pull', + '{% ifversion ai-powered-security-detections %} e AI Scan{% endif %} funcionam em solicitações de pull', + ) } if (context.code === 'zh') { @@ -858,6 +863,21 @@ export function correctTranslatedContentStrings( '你可以使用圆括号嵌套筛选器,最多可达五层深度。{% ifversion ghes < 3.18 %} 目前无法在括号中包含`repo`、`org`或`user`限定符。\n\n{% endwebui %}', '你可以使用圆括号嵌套筛选器,最多可达五层深度。{% ifversion ghes < 3.18 %} 目前无法在括号中包含`repo`、`org`或`user`限定符。{% endif %}\n\n{% endwebui %}', ) + + content = content.replaceAll( + '了解 {% endif %}{% data variables.product.prodname_codeql %} 和 AI 扫描{% ifversion ai-powered-security-detections %}如何', + '了解 {% data variables.product.prodname_codeql %}{% ifversion ai-powered-security-detections %} 和 AI 扫描{% endif %}如何', + ) + + content = content.replaceAll( + '{% data variables.product.prodname_code_quality_short %}{% endif %}、{% data variables.product.prodname_copilot_short %}{% ifversion code-quality %}和', + '{% data variables.product.prodname_copilot_short %}{% ifversion code-quality %}、{% data variables.product.prodname_code_quality_short %}{% endif %}和', + ) + + content = content.replaceAll( + '才需要使用旧代码搜索语法{% else %}。您可以通过在任何组合中使用这些代码搜索限定符来搜索代码并缩小结果范围{% data variables.product.github %}。', + '才需要使用旧代码搜索语法{% else %}您可以通过在任何组合中使用这些代码搜索限定符来搜索代码并缩小结果范围{% data variables.product.github %}{% endif %}。', + ) } if (context.code === 'ru') { @@ -1494,6 +1514,12 @@ export function correctTranslatedContentStrings( '{% data variables.copilot.copilot_autofix_short %} 제안{% endif %} 사용 여부에 따라 수정된 경고 수 {% ifversion code-scanning-autofix %}, 해결되지 않은 상태로 병합된 수', '{% ifversion code-scanning-autofix %} {% data variables.copilot.copilot_autofix_short %} 제안 사용 여부에 따라{% endif %} 수정된 경고 수, 해결되지 않은 상태로 병합된 수', ) + + // The intro is a YAML block scalar, so whitespace after the stray endif varies. + content = content.replace( + /\{% endif %\}\s*\{% data variables\.product\.prodname_codeql %\} 및 AI Scan\{% ifversion ai-powered-security-detections %\}이/g, + '{% data variables.product.prodname_codeql %}{% ifversion ai-powered-security-detections %} 및 AI Scan{% endif %}이', + ) } if (context.code === 'de') { diff --git a/src/languages/tests/correct-translation-content.ts b/src/languages/tests/correct-translation-content.ts index b3b69b0f6259..0392fc1d04ef 100644 --- a/src/languages/tests/correct-translation-content.ts +++ b/src/languages/tests/correct-translation-content.ts @@ -2775,6 +2775,63 @@ Para más información, consulta "[AUTOTITLE](/path)". }) }) + describe('technical-content#8077 per-file fixes', () => { + async function expectRenders(content: string) { + await render(content, 'free-pro-team@latest') + await render(content, ghesVersion) + } + + test('pt: viewing-metrics-for-pull-request-alerts intro adds missing endif', async () => { + const broken = + 'Monitore como {% data variables.product.prodname_codeql %}{% ifversion ai-powered-security-detections %} e AI Scan funcionam em solicitações de pull em suas organizações.' + const fixed = + 'Monitore como {% data variables.product.prodname_codeql %}{% ifversion ai-powered-security-detections %} e AI Scan{% endif %} funcionam em solicitações de pull em suas organizações.' + expect(fix(broken, 'pt')).toBe(fixed) + expect(fix(fixed, 'pt')).toBe(fixed) + await expectRenders(fixed) + }) + + test('zh: code-scanning-on-pull-requests intro swaps endif and ifversion', async () => { + const broken = + '了解 {% endif %}{% data variables.product.prodname_codeql %} 和 AI 扫描{% ifversion ai-powered-security-detections %}如何查找并帮助防止整个组织中的拉取请求漏洞。' + const fixed = + '了解 {% data variables.product.prodname_codeql %}{% ifversion ai-powered-security-detections %} 和 AI 扫描{% endif %}如何查找并帮助防止整个组织中的拉取请求漏洞。' + expect(fix(broken, 'zh')).toBe(fixed) + expect(fix(fixed, 'zh')).toBe(fixed) + await expectRenders(fixed) + }) + + test('zh: download-license-use intro restores ifversion order', async () => { + const broken = + '获取有关{% data variables.product.github %}、{% data variables.product.prodname_code_quality_short %}{% endif %}、{% data variables.product.prodname_copilot_short %}{% ifversion code-quality %}和{% data variables.product.prodname_AS %}许可证消耗情况的数据。' + const fixed = + '获取有关{% data variables.product.github %}、{% data variables.product.prodname_copilot_short %}{% ifversion code-quality %}、{% data variables.product.prodname_code_quality_short %}{% endif %}和{% data variables.product.prodname_AS %}许可证消耗情况的数据。' + expect(fix(broken, 'zh')).toBe(fixed) + expect(fix(fixed, 'zh')).toBe(fixed) + await expectRenders(fixed) + }) + + test('zh: searching-code intro adds missing endif', async () => { + const broken = + '{% ifversion code-search-upgrade %}仅在使用代码搜索 API 时,才需要使用旧代码搜索语法{% else %}。您可以通过在任何组合中使用这些代码搜索限定符来搜索代码并缩小结果范围{% data variables.product.github %}。' + const fixed = + '{% ifversion code-search-upgrade %}仅在使用代码搜索 API 时,才需要使用旧代码搜索语法{% else %}您可以通过在任何组合中使用这些代码搜索限定符来搜索代码并缩小结果范围{% data variables.product.github %}{% endif %}。' + expect(fix(broken, 'zh')).toBe(fixed) + expect(fix(fixed, 'zh')).toBe(fixed) + await expectRenders(fixed) + }) + + test('ko: code-scanning-on-pull-requests intro swaps endif and ifversion', async () => { + const broken = + '{% endif %}\n {% data variables.product.prodname_codeql %} 및 AI Scan{% ifversion ai-powered-security-detections %}이 조직 전체의 풀 리퀘스트에서 취약성을 찾아내고 방지하는 데 도움을 주는 방법을 이해합니다.' + const fixed = + '{% data variables.product.prodname_codeql %}{% ifversion ai-powered-security-detections %} 및 AI Scan{% endif %}이 조직 전체의 풀 리퀘스트에서 취약성을 찾아내고 방지하는 데 도움을 주는 방법을 이해합니다.' + expect(fix(broken, 'ko')).toBe(fixed) + expect(fix(fixed, 'ko')).toBe(fixed) + await expectRenders(fixed) + }) + }) + describe('es: you-can-fork.md per-file fix', () => { test('replaces leading elsif with ifversion', () => { const broken = '{% elsif ghes or ghec %} Puedes bifurcar...' diff --git a/src/search/components/input/AskAIResults.tsx b/src/search/components/input/AskAIResults.tsx index 0c37d2e46c75..04c7cdc98ae7 100644 --- a/src/search/components/input/AskAIResults.tsx +++ b/src/search/components/input/AskAIResults.tsx @@ -169,6 +169,8 @@ export function AskAIResults({ try { const response = await executeAISearch(version, query, debug) + // Parent overlay state is shared, so a stale request must not touch it after unmount. + if (isCancelled) return if (!response.ok) { // Classified non-OK responses include upstreamStatus from the proxy or upstream. let responseJson @@ -177,6 +179,7 @@ export function AskAIResults({ } catch (error) { console.error('Failed to parse JSON:', error) } + if (isCancelled) return const upstreamStatus = responseJson?.upstreamStatus // Missing upstreamStatus leaves this as an unclassified non-OK response. if (!upstreamStatus) { @@ -284,6 +287,7 @@ export function AskAIResults({ while (!done && !isCancelled) { const { value, done: readerDone } = await reader.read() + if (isCancelled) return done = readerDone // Buffer newline-delimited JSON until a whole record arrives; leftover means JSON. diff --git a/src/search/scripts/scrape/lib/build-records-from-api.ts b/src/search/scripts/scrape/lib/build-records-from-api.ts index f9f68c2086b3..9015ab803299 100644 --- a/src/search/scripts/scrape/lib/build-records-from-api.ts +++ b/src/search/scripts/scrape/lib/build-records-from-api.ts @@ -34,6 +34,36 @@ import type { // Strip them so they stay out of search results. const ALERT_MARKER_REGEXP = /\[!(NOTE|TIP|WARNING|IMPORTANT|CAUTION)\]\n?/gi +// The markdown body keeps raw HTML, such as octicon SVGs, kbd tags, and editorial +// comments. Tags become a space so text on either side stays separate tokens. +// The tag pattern follows CommonMark raw HTML so quoted attributes may contain >. +const HTML_COMMENT_OR_TAG_REGEXP = + / + +Inline text. +` + const text = markdownToPlainText(markdown) + expect(text).toContain('Before.') + expect(text).toContain('Inline') + expect(text).toContain('text.') + expect(text).not.toContain('