diff --git a/test/automation/src/agentsWindow.ts b/test/automation/src/agentsWindow.ts index 911bd64b264..78ef507bc63 100644 --- a/test/automation/src/agentsWindow.ts +++ b/test/automation/src/agentsWindow.ts @@ -252,15 +252,47 @@ export class AgentsWindow { * Send a follow-up prompt to the currently active session (after the * new-session view has been dismissed by {@link submitNewSessionPrompt}). * Waits for the send button to be enabled before clicking it. + * + * Pass `expectedActiveLabel` (typically the first response text of the + * session you just activated via {@link activateSessionByLabel}) to also + * re-verify, immediately before clicking send, that the active session + * view still contains a response bubble matching that label. The Agents + * Window can auto-swap the active slot to a fresh untitled session + * after `activateSessionByLabel` returns; without re-checking, the send + * would land in the untitled session and the follow-up never reaches + * the intended conversation. When the check fails the active session is + * re-activated and the prompt is re-typed before sending. */ - async sendFollowUpMessage(prompt: string, sendButtonRetryCount: number = 600): Promise { - await this.code.waitForElement(ACTIVE_SESSION_INPUT_EDITOR); - await this.code.waitAndClick(ACTIVE_SESSION_INPUT_EDITOR); - await this.code.waitForTypeInEditor(this.activeSessionInputSelector, prompt); - await this.code.waitForElement(ACTIVE_SESSION_SEND_BUTTON_ENABLED, undefined, sendButtonRetryCount); + async sendFollowUpMessage(prompt: string, sendButtonRetryCount: number = 600, expectedActiveLabel?: string): Promise { + const typeAndSend = async () => { + await this.code.waitForElement(ACTIVE_SESSION_INPUT_EDITOR); + await this.code.waitAndClick(ACTIVE_SESSION_INPUT_EDITOR); + await this.code.waitForTypeInEditor(this.activeSessionInputSelector, prompt); + await this.code.waitForElement(ACTIVE_SESSION_SEND_BUTTON_ENABLED, undefined, sendButtonRetryCount); + }; + + await typeAndSend(); + + if (expectedActiveLabel) { + const stillActive = await this._activeSessionContainsResponse(expectedActiveLabel); + if (!stillActive) { + // The active slot swapped between activation and send. Re-bind + // and re-type the prompt before sending. + await this.activateSessionByLabel(expectedActiveLabel); + await typeAndSend(); + } + } + await this.code.waitAndClick(ACTIVE_SESSION_SEND_BUTTON_ENABLED); } + private async _activeSessionContainsResponse(label: string): Promise { + const activeResponseSelector = `${ACTIVE_SESSION} .interactive-item-container.interactive-response .rendered-markdown`; + const responses = await this.code.getElements(activeResponseSelector, /* recursive */ true); + const needle = label.toLowerCase(); + return (responses ?? []).some(r => (r.textContent ?? '').toLowerCase().includes(needle)); + } + /** * Click the session in the sidebar whose item text contains `label` to * make it the active session view. This is needed for session types @@ -364,13 +396,23 @@ export class AgentsWindow { const deadline = Date.now() + timeoutMs; let lastTexts: string[] = []; while (Date.now() < deadline) { + // Look in BOTH the active session view and the broader workbench + // scope. The Agents Window can auto-swap the active slot to a + // fresh untitled session immediately after a follow-up commits, + // which leaves the just-arrived assistant reply visible only in + // the previous session's DOM (the rows haven't been recycled yet + // or the session lives in another non-active slot). Scoping + // strictly to `.session-view.is-active` would then miss the + // match even though the response did render. The wider scope + // also covers the case where the active slot is still bound to + // the originating session but multiple slots are present. const activeResponseElements = await this.code.getElements(activeResponseSelector, /* recursive */ true); - lastTexts = (activeResponseElements ?? []).map(el => el.textContent || ''); - if (lastTexts.length === 0) { - const markdownElements = await this.code.getElements(markdownResponseSelector, /* recursive */ true); - lastTexts = (markdownElements ?? []).map(el => el.textContent || ''); - } - for (const text of lastTexts) { + const activeTexts = (activeResponseElements ?? []).map(el => el.textContent || ''); + const markdownElements = await this.code.getElements(markdownResponseSelector, /* recursive */ true); + const markdownTexts = (markdownElements ?? []).map(el => el.textContent || ''); + lastTexts = activeTexts.length ? activeTexts : markdownTexts; + const candidates = [...activeTexts, ...markdownTexts]; + for (const text of candidates) { if (typeof predicate === 'string' ? text.includes(predicate) : predicate.test(text)) { // Give the chat session a grace period to transition out of the // in-progress state before returning. The chat-request lifecycle diff --git a/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts b/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts index 44ef8a2cf59..9ac04a886cb 100644 --- a/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts +++ b/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts @@ -235,7 +235,17 @@ export function setup(logger: Logger) { // Follow-up message in the same session — exercises the // active-session input path (not the new-session homepage). - await app.workbench.agentsWindow.sendFollowUpMessage(`hello again [scenario:${session.scenarioId2}]`); + // For Copilot CLI, pass the expected active label so + // `sendFollowUpMessage` re-verifies the active slot right + // before sending (the workbench can auto-swap the slot to + // a fresh untitled session between `activateSessionByLabel` + // returning and the send-button click). + const expectedActiveLabel = session.name === 'Copilot CLI' ? session.reply : undefined; + await app.workbench.agentsWindow.sendFollowUpMessage( + `hello again [scenario:${session.scenarioId2}]`, + undefined, + expectedActiveLabel, + ); const secondTurnTimeout = session.name === 'Copilot CLI' ? 180_000 : 60_000; const text2 = await app.workbench.agentsWindow.waitForAssistantText(session.reply2, secondTurnTimeout);