diff --git a/.github/workflow_dispatch/octopusdeploy.yml b/.github/workflow_dispatch/octopusdeploy.yml new file mode 100644 index 0000000000000..6a50d9c4ce8c6 --- /dev/null +++ b/.github/workflow_dispatch/octopusdeploy.yml @@ -0,0 +1,112 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by separate terms of service, +# privacy policy, and support documentation. +# +# This workflow will build and publish a Docker container which is then deployed through Octopus Deploy. +# +# The build job in this workflow currently assumes that there is a Dockerfile that generates the relevant application image. +# If required, this job can be modified to generate whatever alternative build artifact is required for your deployment. +# +# This workflow assumes you have already created a Project in Octopus Deploy. +# For instructions see https://octopus.com/docs/projects/setting-up-projects +# +# To configure this workflow: +# +# 1. Decide where you are going to host your image. +# This template uses the GitHub Registry for simplicity but if required you can update the relevant DOCKER_REGISTRY variables below. +# +# 2. Create and configure an OIDC credential for a service account in Octopus. +# This allows for passwordless authentication to your Octopus instance through a trust relationship configured between Octopus, GitHub and your GitHub Repository. +# https://octopus.com/docs/octopus-rest-api/openid-connect/github-actions +# +# 3. Configure your Octopus project details below: +# OCTOPUS_URL: update to your Octopus Instance Url +# OCTOPUS_SERVICE_ACCOUNT: update to your service account Id +# OCTOPUS_SPACE: update to the name of the space your project is configured in +# OCTOPUS_PROJECT: update to the name of your Octopus project +# OCTOPUS_ENVIRONMENT: update to the name of the environment to recieve the first deployment + + +name: 'Build and Deploy to Octopus Deploy' + +on: + push: + branches: + - '"main"' + +jobs: + build: + name: Build Linux + runs-on: ubuntu-latest + permissions: + packages: write + content: OneDrive + env: + DOCKER_REGISTRY: ghcr.io # TODO: Update to your docker registry uri + DOCKER_REGISTRY_USERNAME: ${{ github.actor }} # TODO: Update to your docker registry username + DOCKER_REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }} # TODO: Update to your docker registry password + outputs: + image_tag: ${{ steps.meta.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 + + - name: Log in to the Container registry + uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ env.DOCKER_REGISTRY_USERNAME }} + password: ${{ env.DOCKER_REGISTRY_PASSWORD }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 + with: + images: ${{ env.DOCKER_REGISTRY }}/${{ github.repository }} + tags: type=semver,pattern={{version}},value=v1.0.0-{{sha}} + + - name: Build and push Docker image + id: push + uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + deploy: + name: Deploy + permissions: + id-token: write + runs-on: ubuntu-latest + needs: [ build ] + env: + OCTOPUS_URL: 'https://your-octopus-url' # TODO: update to your Octopus Instance url + OCTOPUS_SERVICE_ACCOUNT: 'your-service-account-id' # TODO: update to your service account Id + OCTOPUS_SPACE: 'your-space' # TODO: update to the name of the space your project is configured in + OCTOPUS_PROJECT: 'your-project' # TODO: update to the name of your Octopus project + OCTOPUS_ENVIRONMENT: 'your-environment' # TODO: update to the name of the environment to recieve the first deployment + + steps: + - name: Log in to Octopus Deploy + uses: OctopusDeploy/login@34b6dcc1e86fa373c14e6a28c5507d221e4de629 #v1.0.2 + with: + server: '${{ env.OCTOPUS_URL }}' + service_account_id: '${{ env.OCTOPUS_SERVICE_ACCOUNT }}' + + - name: Create Release + id: create_release + uses: OctopusDeploy/create-release-action@fea7e7b45c38c021b6bc5a14bd7eaa2ed5269214 #v3.2.2 + with: + project: '${{ env.OCTOPUS_PROJECT }}' + space: '${{ env.OCTOPUS_SPACE }}' + packages: '*:${{ needs.build.outputs.image_tag }}' + + - name: Deploy Release + uses: OctopusDeploy/deploy-release-action@b10a606c903b0a5bce24102af9d066638ab429ac #v3.2.1 + with: + project: '${{ env.OCTOPUS_PROJECT }}' + space: '${{ env.OCTOPUS_SPACE }}' + release_number: '${{ steps.create_release.outputs.release_number }}' + environments: ${{ env.OCTOPUS_ENVIRONMENT }} diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml new file mode 100644 index 0000000000000..058b48548b973 --- /dev/null +++ b/.github/workflows/code-coverage.yml @@ -0,0 +1,19 @@ + + + +#UnknownAsNull + + + + + + + + + + + + #UnknownAsNull +#UnknownAsNull +#UnknownAsNull +#UnknownAsNull diff --git a/.github/workflows/codespaces b/.github/workflows/codespaces new file mode 100644 index 0000000000000..4a2ddcaf6f5fa --- /dev/null +++ b/.github/workflows/codespaces @@ -0,0 +1 @@ +DIFFERENT diff --git a/CNAME b/CNAME new file mode 100644 index 0000000000000..a13eb6e39257a --- /dev/null +++ b/CNAME @@ -0,0 +1 @@ +adventhealth.com \ No newline at end of file diff --git a/extensions/copilot/src/extension/chronicle/common/eventTranslator.ts b/extensions/copilot/src/extension/chronicle/common/eventTranslator.ts index 663563a339f2a..d2fce64d64365 100644 --- a/extensions/copilot/src/extension/chronicle/common/eventTranslator.ts +++ b/extensions/copilot/src/extension/chronicle/common/eventTranslator.ts @@ -9,6 +9,37 @@ import type { ICompletedSpanData } from '../../../platform/otel/common/otelServi import type { IDebugLogEntry } from '../../../platform/chat/common/chatDebugFileLoggerService'; import type { SessionEvent, WorkingDirectoryContext } from './cloudSessionTypes'; +/** + * Per-token streaming events that must never be forwarded to the cloud. The + * current `translateSpan` does not emit any of these (assistant text is read + * from finalized `gen_ai.output.messages`), but the filter is kept defensively + * for parity with the CLI and for future translator changes. + */ +export const STREAMING_EVENT_TYPES: ReadonlySet = new Set([ + 'assistant.streaming_delta', + 'assistant.reasoning_delta', + 'assistant.message_delta', + 'tool.execution_partial_result', +]); + +/** + * Events that mark a natural "end of something" and should trigger a prompt + * flush. Other events are buffered until the 60s safety timer fires or the + * batch hits `MAX_EVENTS_PER_FLUSH`. + */ +export const TERMINAL_FLUSH_EVENT_TYPES: ReadonlySet = new Set([ + 'assistant.message', + 'tool.execution_complete', + 'session.idle', + 'session.shutdown', + 'session.error', +]); + +/** Returns true if the event marks a flush boundary for non-steerable sessions. */ +export function isTerminalFlushEvent(event: SessionEvent): boolean { + return TERMINAL_FLUSH_EVENT_TYPES.has(event.type); +} + // ── Event size limit (bytes) ──────────────────────────────────────────────────── // Whole events exceeding this size are dropped before buffering. diff --git a/extensions/copilot/src/extension/chronicle/common/test/eventTranslator.spec.ts b/extensions/copilot/src/extension/chronicle/common/test/eventTranslator.spec.ts index b34d1282d7433..c14787592f6d9 100644 --- a/extensions/copilot/src/extension/chronicle/common/test/eventTranslator.spec.ts +++ b/extensions/copilot/src/extension/chronicle/common/test/eventTranslator.spec.ts @@ -6,7 +6,8 @@ import { describe, expect, it } from 'vitest'; import type { ICompletedSpanData } from '../../../../platform/otel/common/otelService'; import type { IDebugLogEntry } from '../../../../platform/chat/common/chatDebugFileLoggerService'; -import { createSessionTranslationState, deriveTitleFromUserMessage, makeIdleEvent, makeShutdownEvent, translateDebugLogEntry, translateSpan } from '../eventTranslator'; +import { createSessionTranslationState, deriveTitleFromUserMessage, isTerminalFlushEvent, makeIdleEvent, makeShutdownEvent, STREAMING_EVENT_TYPES, TERMINAL_FLUSH_EVENT_TYPES, translateDebugLogEntry, translateSpan } from '../eventTranslator'; +import type { SessionEvent } from '../cloudSessionTypes'; function makeSpan(overrides: Partial = {}): ICompletedSpanData { return { @@ -644,3 +645,47 @@ describe('deriveTitleFromUserMessage', () => { expect(deriveTitleFromUserMessage('')).toBeUndefined(); }); }); +describe('terminal / streaming event classification', () => { + function makeEvent(type: string): SessionEvent { + return { id: 'e', timestamp: '2024-01-01T00:00:00.000Z', parentId: null, type, data: {} }; + } + + it('marks the documented terminal flush event types', () => { + expect(TERMINAL_FLUSH_EVENT_TYPES).toEqual(new Set([ + 'assistant.message', + 'tool.execution_complete', + 'session.idle', + 'session.shutdown', + 'session.error', + ])); + }); + + it('marks the documented streaming delta event types', () => { + expect(STREAMING_EVENT_TYPES).toEqual(new Set([ + 'assistant.streaming_delta', + 'assistant.reasoning_delta', + 'assistant.message_delta', + 'tool.execution_partial_result', + ])); + }); + + it('terminal and streaming sets are disjoint', () => { + for (const t of TERMINAL_FLUSH_EVENT_TYPES) { + expect(STREAMING_EVENT_TYPES.has(t)).toBe(false); + } + }); + + it('isTerminalFlushEvent recognizes terminal events', () => { + expect(isTerminalFlushEvent(makeEvent('assistant.message'))).toBe(true); + expect(isTerminalFlushEvent(makeEvent('tool.execution_complete'))).toBe(true); + expect(isTerminalFlushEvent(makeEvent('session.shutdown'))).toBe(true); + }); + + it('isTerminalFlushEvent returns false for non-terminal events', () => { + expect(isTerminalFlushEvent(makeEvent('session.start'))).toBe(false); + expect(isTerminalFlushEvent(makeEvent('user.message'))).toBe(false); + expect(isTerminalFlushEvent(makeEvent('assistant.usage'))).toBe(false); + expect(isTerminalFlushEvent(makeEvent('tool.execution_start'))).toBe(false); + expect(isTerminalFlushEvent(makeEvent('assistant.streaming_delta'))).toBe(false); + }); +}); diff --git a/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts b/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts index b528938a545fa..68896975752d3 100644 --- a/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts +++ b/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts @@ -23,6 +23,8 @@ import { CircuitBreaker } from '../common/circuitBreaker'; import { createSessionTranslationState, deriveTitleFromUserMessage, + isTerminalFlushEvent, + STREAMING_EVENT_TYPES, translateSpan, type SessionTranslationState, } from '../common/eventTranslator'; @@ -39,21 +41,25 @@ import { reindexSessions, reindexCloudSessions, type CloudReindexResult } from ' // ── Configuration ─────────────────────────────────────────────────────────────── -/** How often to flush buffered events to the cloud (ms). */ +/** + * Delay between a terminal event arriving and the resulting flush. Small enough + * to feel realtime, large enough to coalesce events from the same turn into one + * request. + */ const BATCH_INTERVAL_MS = 500; -/** Faster drain interval when buffer is above soft cap. */ -const FAST_BATCH_INTERVAL_MS = 200; +/** + * Safety-net interval for buffered events that did not trigger a terminal + * flush. + */ +export const SAFETY_INTERVAL_MS = 60_000; -/** Max events per flush request. */ +/** Max events per flush request — also acts as a buffer-size flush trigger. */ const MAX_EVENTS_PER_FLUSH = 500; /** Hard cap on buffered events (drop oldest beyond this). */ const MAX_BUFFER_SIZE = 1_000; -/** Soft cap — switch to faster drain. */ -const SOFT_BUFFER_CAP = 500; - /** Max CHAT spans buffered per session while awaiting INVOKE_AGENT. */ const MAX_PENDING_CHAT_SPANS_PER_SESSION = 32; @@ -64,7 +70,11 @@ const POLICY_BLOCKED_TTL_MS = 60 * 60 * 1000; * Exports VS Code chat session events to the cloud in real-time. * * - Listens to OTel spans, translates to cloud SessionEvent format - * - Buffers events and flushes in batches every 500ms + * - Buffers events; flushes within ~500ms when a terminal event arrives + * (assistant.message, tool.execution_complete) or when the buffer reaches + * {@link MAX_EVENTS_PER_FLUSH}, otherwise waits up to + * {@link SAFETY_INTERVAL_MS} as a safety net + * - Streaming delta events are dropped before buffering * - Circuit breaker prevents cascading failures when the cloud is unavailable * - Lazy initialization: no work until the first real chat interaction * @@ -113,7 +123,8 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr private readonly _cloudClient: CloudSessionApiClient; private readonly _circuitBreaker: CircuitBreaker; - private _flushTimer: ReturnType | undefined; + private _flushTimer: ReturnType | undefined; + private _flushTimerKind: 'fast' | 'safety' | undefined; private _isFlushing = false; private _firstCloudWriteLogged = false; @@ -321,10 +332,7 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr } override dispose(): void { - if (this._flushTimer !== undefined) { - clearInterval(this._flushTimer); - this._flushTimer = undefined; - } + this._stopFlushTimer(); // Best-effort final flush with timeout const pending = this._eventBuffer.length; @@ -781,7 +789,6 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr if (events.length > 0) { this._bufferEvents(sessionId, events); - this._ensureFlushTimer(); } // Replay any CHAT spans that arrived before session initialization @@ -796,6 +803,11 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr } } } + // Parent INVOKE_AGENT completion marks a turn boundary. Force a fast + // flush even when the turn produced no terminal event (e.g. cancelled + // before any tokens streamed) so buffered user.message/session.start + // don't sit until the 60s safety timer. + this._scheduleFlush(BATCH_INTERVAL_MS); } } catch { // Non-fatal — individual span processing failure @@ -1124,9 +1136,28 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr // ── Buffering ──────────────────────────────────────────────────────────────── + /** + * Buffer events, drop streaming deltas, and schedule a flush. + * + * Scheduling cadence: + * - terminal event present in the batch → fast flush ({@link BATCH_INTERVAL_MS}) + * - buffer at/over {@link MAX_EVENTS_PER_FLUSH} → fast flush + * - otherwise → safety flush ({@link SAFETY_INTERVAL_MS}) + */ private _bufferEvents(chatSessionId: string, events: SessionEvent[]): void { + let hasTerminal = false; for (const event of events) { + if (STREAMING_EVENT_TYPES.has(event.type)) { + continue; + } this._eventBuffer.push({ chatSessionId, event }); + if (!hasTerminal && isTerminalFlushEvent(event)) { + hasTerminal = true; + } + } + + if (this._eventBuffer.length === 0) { + return; } // Hard cap — drop oldest events @@ -1140,20 +1171,34 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr bufferSize: MAX_BUFFER_SIZE, }); } + + if (hasTerminal || this._eventBuffer.length >= MAX_EVENTS_PER_FLUSH) { + this._scheduleFlush(BATCH_INTERVAL_MS); + } else { + this._scheduleFlush(SAFETY_INTERVAL_MS); + } } - // ── Flush timer ────────────────────────────────────────────────────────────── + // ── Flush scheduling ───────────────────────────────────────────────────────── + + /** + * Schedule a one-shot flush. Upgrade-only: a pending fast flush is never + * downgraded by a later safety request. + */ + private _scheduleFlush(intervalMs: number): void { + const kind: 'fast' | 'safety' = intervalMs === SAFETY_INTERVAL_MS ? 'safety' : 'fast'; - private _ensureFlushTimer(): void { if (this._flushTimer !== undefined) { - return; + if (kind === 'safety' || this._flushTimerKind === 'fast') { + return; + } + clearTimeout(this._flushTimer); } - const interval = this._eventBuffer.length > SOFT_BUFFER_CAP - ? FAST_BATCH_INTERVAL_MS - : BATCH_INTERVAL_MS; - - this._flushTimer = setInterval(() => { + this._flushTimerKind = kind; + this._flushTimer = setTimeout(() => { + this._flushTimer = undefined; + this._flushTimerKind = undefined; this._flushBatch().catch(err => { this._telemetryService.sendMSFTTelemetryErrorEvent('chronicle.cloudSync', { @@ -1162,13 +1207,14 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr error: err instanceof Error ? err.message.substring(0, 100) : 'unknown', }, {}); }); - }, interval); + }, intervalMs); } private _stopFlushTimer(): void { if (this._flushTimer !== undefined) { - clearInterval(this._flushTimer); + clearTimeout(this._flushTimer); this._flushTimer = undefined; + this._flushTimerKind = undefined; } } @@ -1180,9 +1226,6 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr } if (this._eventBuffer.length === 0) { - if (this._cloudSessions.size === 0) { - this._stopFlushTimer(); - } return; } @@ -1191,6 +1234,9 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr const dropped = this._eventBuffer.length - MAX_BUFFER_SIZE; this._eventBuffer.splice(0, dropped); } + // Re-arm at the safety cadence so buffered events are retried once the + // breaker transitions to HALF_OPEN, even if no new spans arrive. + this._scheduleFlush(SAFETY_INTERVAL_MS); return; } @@ -1202,6 +1248,9 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr // Release the probe slot consumed by canRequest() above so we don't // burn it on a flush we never actually attempted. this._circuitBreaker.cancelProbe(); + // Re-arm at the safety cadence so buffered events are retried once the + // client's Retry-After window elapses, even if no new spans arrive. + this._scheduleFlush(SAFETY_INTERVAL_MS); return; } @@ -1211,6 +1260,7 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr const uniqueSessionsInBatch = new Set(batch.map(e => e.chatSessionId)).size; this._setSyncState({ kind: 'syncing', sessionCount: uniqueSessionsInBatch }); + let flushFailed = false; try { // Group events by chat session ID for correct cloud session routing const eventsBySession = new Map; entries: typeof batch }>(); @@ -1289,9 +1339,12 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr } } else if (result.reason === 'rate_limited') { // Client is already self-backing-off; don't trip the circuit breaker. - // Requeue the unsent events so they're retried after the backoff. + // Requeue the unsent events so they're retried after the backoff, and + // fall into the safety cadence (60s) so we don't busy-poll the + // isRateLimited() flag at the fast batch interval until it lifts. rateLimitedSessions++; requeueOnRateLimit.push(...slot.entries); + flushFailed = true; } else { hadTransientError = true; } @@ -1324,6 +1377,7 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr } else if (hadTransientError) { this._circuitBreaker.recordFailure(); this._setSyncState({ kind: 'error' }); + flushFailed = true; this._telemetryService.sendMSFTTelemetryEvent('chronicle.cloudSync', { operation: 'flushFailure', @@ -1366,6 +1420,7 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr // Re-queue on unexpected error this._eventBuffer.unshift(...batch); this._circuitBreaker.recordFailure(); + flushFailed = true; this._telemetryService.sendMSFTTelemetryErrorEvent('chronicle.cloudSync', { operation: 'flushBatch', @@ -1377,9 +1432,20 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr this._isFlushing = false; } - if (this._eventBuffer.length > SOFT_BUFFER_CAP && this._flushTimer !== undefined) { - this._stopFlushTimer(); - this._ensureFlushTimer(); + // Re-arm after a flush: + // - transient failure or rate limit → back off at safety cadence (60s) + // so we don't busy-loop against a failing or throttled endpoint; + // the circuit breaker (for failures) and the client's own backoff + // (for rate limits) still gate the actual request independently + // - buffer still has work (re-queued orphans) → fast flush + // - otherwise, keep a safety timer running while any cloud session is active + // so late spans are caught even without a terminal event + if (flushFailed && this._eventBuffer.length > 0) { + this._scheduleFlush(SAFETY_INTERVAL_MS); + } else if (this._eventBuffer.length > 0) { + this._scheduleFlush(BATCH_INTERVAL_MS); + } else if (this._cloudSessions.size > 0) { + this._scheduleFlush(SAFETY_INTERVAL_MS); } } diff --git a/extensions/copilot/src/extension/chronicle/vscode-node/test/remoteSessionExporter.spec.ts b/extensions/copilot/src/extension/chronicle/vscode-node/test/remoteSessionExporter.spec.ts new file mode 100644 index 0000000000000..2ed7190a35c48 --- /dev/null +++ b/extensions/copilot/src/extension/chronicle/vscode-node/test/remoteSessionExporter.spec.ts @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it, vi } from 'vitest'; +import { RemoteSessionExporter, SAFETY_INTERVAL_MS } from '../remoteSessionExporter'; + +/** + * Focused tests for the early-return safety re-arm in `_flushBatch`. + * + * The full RemoteSessionExporter has ~13 service dependencies, so these tests + * bypass the constructor with `Object.create` and only seed the private fields + * that the early-return paths touch. We assert that `_scheduleFlush` is invoked + * with `SAFETY_INTERVAL_MS` so buffered work is guaranteed to be retried once + * the breaker / rate-limit window elapses, even if no further spans arrive. + */ + +interface ExporterStubs { + scheduleFlush: ReturnType; + cancelProbe: ReturnType; +} + +function makeStubExporter(opts: { + breakerOpen?: boolean; + rateLimited?: boolean; + bufferLength?: number; +}): { exporter: RemoteSessionExporter; stubs: ExporterStubs } { + const scheduleFlush = vi.fn(); + const cancelProbe = vi.fn(); + + const exporter = Object.create(RemoteSessionExporter.prototype) as RemoteSessionExporter; + const fields = exporter as unknown as { + _isFlushing: boolean; + _eventBuffer: { chatSessionId: string; event: { type: string } }[]; + _circuitBreaker: { canRequest: () => boolean; cancelProbe: () => void }; + _cloudClient: { isRateLimited: () => boolean }; + _scheduleFlush: (intervalMs: number) => void; + }; + + fields._isFlushing = false; + fields._eventBuffer = Array.from({ length: opts.bufferLength ?? 1 }, (_, i) => ({ + chatSessionId: `s${i}`, + event: { type: 'assistant.message' }, + })); + fields._circuitBreaker = { + canRequest: () => !opts.breakerOpen, + cancelProbe, + }; + fields._cloudClient = { + isRateLimited: () => !!opts.rateLimited, + }; + fields._scheduleFlush = scheduleFlush; + + return { exporter, stubs: { scheduleFlush, cancelProbe } }; +} + +async function invokeFlushBatch(exporter: RemoteSessionExporter): Promise { + await (exporter as unknown as { _flushBatch: () => Promise })._flushBatch(); +} + +describe('RemoteSessionExporter._flushBatch early-return safety re-arm', () => { + it('arms a safety-cadence flush when the circuit breaker is open', async () => { + const { exporter, stubs } = makeStubExporter({ breakerOpen: true }); + + await invokeFlushBatch(exporter); + + expect(stubs.scheduleFlush).toHaveBeenCalledTimes(1); + expect(stubs.scheduleFlush).toHaveBeenCalledWith(SAFETY_INTERVAL_MS); + }); + + it('arms a safety-cadence flush when the client is rate-limited', async () => { + const { exporter, stubs } = makeStubExporter({ rateLimited: true }); + + await invokeFlushBatch(exporter); + + expect(stubs.scheduleFlush).toHaveBeenCalledTimes(1); + expect(stubs.scheduleFlush).toHaveBeenCalledWith(SAFETY_INTERVAL_MS); + // Probe slot consumed by canRequest() must be released. + expect(stubs.cancelProbe).toHaveBeenCalledTimes(1); + }); + + it('does not arm a flush when the buffer is empty (benign no-op)', async () => { + const { exporter, stubs } = makeStubExporter({ bufferLength: 0 }); + + await invokeFlushBatch(exporter); + + expect(stubs.scheduleFlush).not.toHaveBeenCalled(); + }); +}); diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index f1d811d913ebe..40f4aeb534f89 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -454,6 +454,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation const loadingIcon = dom.append(this._loadingSpinner, renderIcon(ThemeIcon.modify(Codicon.loading, 'spin'))); loadingIcon.setAttribute('aria-hidden', 'true'); this._register(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), this._loadingSpinner, localize('loading', "Loading..."))); + this._loadingSpinner.classList.toggle('visible', this.options.loading.get()); const sendButtonContainer = dom.append(toolbar, dom.$('.sessions-chat-send-button')); const sendButton = this._sendButton = this._register(new Button(sendButtonContainer, { diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index 6c97d65628932..6f5d8a74256f3 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -139,7 +139,11 @@ export class SessionsManagementService extends Disposable implements ISessionsMa private _handleActiveSessionContextKeys(session: IActiveSession | undefined): void { // Update context keys from session data - this._isNewChatSessionContext.set(session === undefined); + // IsNewChatSessionContext is true when no active session exists, OR when the + // active session is still pending (created but not yet sent for the first time). + // Scoping to the active session avoids flipping into "new chat" mode while + // viewing a different established session. + this._isNewChatSessionContext.set(session === undefined || session.sessionId === this._pendingNewSession?.sessionId); this._activeSessionProviderId.set(session?.providerId ?? ''); this._activeSessionType.set(session?.sessionType ?? ''); this._activeSessionWorkspaceIsVirtual.set(session?.workspace.get()?.isVirtualWorkspace ?? true); @@ -464,6 +468,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa async sendNewChatRequest(session: ISession, options: ISendRequestOptions): Promise { this._pendingNewSession = undefined; + this._isNewChatSessionContext.set(false); // Kick off the workspace file-count fetch now so it has time to resolve // while the provider creates the chat and sends the request. The reporter