88// PostHog client, so `trackEvent` is a no-op. Their names and properties are
99// fixed by the typed catalog in `packages/react/src/api/analytics.tsx`.
1010import { expect } from "@effect/vitest" ;
11- import { Effect } from "effect" ;
11+ import { Duration , Effect } from "effect" ;
1212import { Client } from "@modelcontextprotocol/sdk/client/index.js" ;
13- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" ;
14- import type { Page } from "playwright" ;
13+ import {
14+ StreamableHTTPClientTransport ,
15+ StreamableHTTPError ,
16+ } from "@modelcontextprotocol/sdk/client/streamableHttp.js" ;
17+ import type { Locator , Page } from "playwright" ;
1518
1619import { scenario } from "../src/scenario" ;
1720import { Browser , Mcp , Target } from "../src/services" ;
1821import { revisit , settle , visit } from "../src/surfaces/browser" ;
1922import type { Identity , Target as TargetShape } from "../src/target" ;
2023import { WORKOS_EMULATOR_PORT } from "../targets/cloud" ;
24+ import { configuredMcpSessionTimeoutMs } from "../setup/mcp-session-timeouts" ;
25+ import { E2E_MCP_V2_MOVED_RECHECK_MS } from "../setup/v2-moved-recheck" ;
2126import { verifyAdmin } from "./support/admin-mfa" ;
2227import { cookieOf } from "./support/session" ;
2328
@@ -86,9 +91,9 @@ const setOrgMetadata = (
8691 await response . body ?. cancel ( ) ;
8792 } ) ;
8893
89- const markMoved = ( organizationId : string ) =>
94+ const markMoved = ( organizationId : string , slug : string = V2_SLUG ) =>
9095 setOrgMetadata ( organizationId , {
91- executor_v2_org_slug : V2_SLUG ,
96+ executor_v2_org_slug : slug ,
9297 executor_v2_moved_at : MOVED_AT ,
9398 } ) ;
9499
@@ -127,9 +132,10 @@ const v2McpUrlOf = (target: TargetShape, answer: V2ImportAnswer): string => {
127132
128133const emailOf = ( identity : Identity ) : string => identity . credentials ?. email ?? identity . label ;
129134
130- /** A real MCP client's handshake: the server's `initialize` instructions. */
131- const initializeInstructions = ( mcpUrl : string , bearer : string ) =>
132- Effect . acquireUseRelease (
135+ /** A real MCP client, connected (the `initialize` handshake done) for the
136+ * scope, closed after. */
137+ const connectedClient = ( mcpUrl : string , bearer : string ) =>
138+ Effect . acquireRelease (
133139 Effect . promise ( async ( ) => {
134140 const client = new Client ( { name : "executor-e2e-v2-moved" , version : "0.0.1" } ) ;
135141 await client . connect (
@@ -139,14 +145,55 @@ const initializeInstructions = (mcpUrl: string, bearer: string) =>
139145 ) ;
140146 return client ;
141147 } ) ,
142- ( client ) =>
143- Effect . promise ( async ( ) => ( {
148+ ( client ) => Effect . promise ( ( ) => client . close ( ) . catch ( ( ) => undefined ) ) ,
149+ ) ;
150+
151+ /** A real MCP client's handshake: the server's `initialize` instructions. */
152+ const initializeInstructions = ( mcpUrl : string , bearer : string ) =>
153+ Effect . scoped (
154+ Effect . gen ( function * ( ) {
155+ const client = yield * connectedClient ( mcpUrl , bearer ) ;
156+ return yield * Effect . promise ( async ( ) => ( {
144157 instructions : client . getInstructions ( ) ,
145158 tools : ( await client . listTools ( ) ) . tools . map ( ( tool ) => tool . name ) ,
146- } ) ) ,
147- ( client ) => Effect . promise ( ( ) => client . close ( ) . catch ( ( ) => undefined ) ) ,
159+ } ) ) ;
160+ } ) ,
148161 ) ;
149162
163+ /** The HTTP status a request on the client's session answers, or 200. */
164+ const listToolsStatus = ( client : Client ) : Effect . Effect < number > =>
165+ Effect . promise ( ( ) =>
166+ client . listTools ( ) . then (
167+ ( ) => 200 ,
168+ ( error : unknown ) => {
169+ if ( error instanceof StreamableHTTPError && error . code !== undefined ) return error . code ;
170+ throw error ;
171+ } ,
172+ ) ,
173+ ) ;
174+
175+ const movedInstructions = ( v2McpUrl : string , slug : string = V2_SLUG ) =>
176+ "This organization moved to Executor v2 on October 9, 2026. " +
177+ `Its MCP server is now ${ v2McpUrl } ; ask the user to reconnect their agent there ` +
178+ `and choose the ${ slug } organization when signing in. ` +
179+ "Changes made through this server won't reach v2." ;
180+
181+ /** Where an element's text is drawn, which overflows the element's own box
182+ * when the text cannot wrap. */
183+ const textExtent = ( locator : Locator ) =>
184+ locator . evaluate ( ( element ) => {
185+ const range = document . createRange ( ) ;
186+ range . selectNodeContents ( element ) ;
187+ const box = range . getBoundingClientRect ( ) ;
188+ return { left : box . left , right : box . right } ;
189+ } ) ;
190+
191+ const boxOf = async ( locator : Locator , label : string ) => {
192+ const box = await locator . boundingBox ( ) ;
193+ if ( box === null ) throw new Error ( `${ label } has no layout box` ) ;
194+ return { left : box . x , right : box . x + box . width } ;
195+ } ;
196+
150197/** Open the dashboard and wait for the v2 answer it renders the notice from. */
151198const openDashboard = async ( page : Page , slug : string ) => {
152199 const answered = page . waitForResponse ( ( response ) => response . url ( ) . endsWith ( V2_IMPORT_API ) ) ;
@@ -460,11 +507,121 @@ scenario(
460507
461508 const after = yield * initializeInstructions ( target . mcpUrl , bearer ) ;
462509 expect ( after . instructions , "the initialize result names the org's v2 MCP server" ) . toBe (
463- "This organization moved to Executor v2 on October 9, 2026. " +
464- `Its MCP server is now ${ v2McpUrl } ; ask the user to reconnect their agent there ` +
465- `and choose the ${ V2_SLUG } organization when signing in. ` +
466- "Changes made through this server won't reach v2." ,
510+ movedInstructions ( v2McpUrl ) ,
467511 ) ;
468512 expect ( after . tools , "the tools are unchanged" ) . toEqual ( before . tools ) ;
469513 } ) ,
470514) ;
515+
516+ /**
517+ * An agent connected before the move got `initialize` instructions without the
518+ * notice, and instructions only travel in `initialize`. Its session ends (HTTP
519+ * 404, which tells a Streamable HTTP client to start a new session), and the
520+ * new session's `initialize` carries the notice. `wait` is how long the agent
521+ * stays quiet after the move.
522+ */
523+ const connectedBeforeTheMove = ( wait : Duration . Input ) =>
524+ Effect . scoped (
525+ Effect . gen ( function * ( ) {
526+ const target = yield * Target ;
527+ const mcp = yield * Mcp ;
528+ const identity = yield * target . newIdentity ( ) ;
529+ const org = yield * orgOf ( target , identity ) ;
530+ const bearer = yield * mcp . mintBearer ( emailOf ( identity ) ) ;
531+
532+ const early = yield * connectedClient ( target . mcpUrl , bearer ) ;
533+ expect ( early . getInstructions ( ) , "an org that has not moved gets no notice" ) . toBeUndefined ( ) ;
534+ expect ( yield * listToolsStatus ( early ) , "the session works before the move" ) . toBe ( 200 ) ;
535+
536+ yield * markMoved ( org . id ) ;
537+ const v2McpUrl = v2McpUrlOf ( target , yield * v2ImportOf ( target , identity ) ) ;
538+ yield * Effect . sleep ( wait ) ;
539+
540+ expect (
541+ yield * listToolsStatus ( early ) ,
542+ "the next request ends the session, so the client starts a new one" ,
543+ ) . toBe ( 404 ) ;
544+
545+ const again = yield * connectedClient ( target . mcpUrl , bearer ) ;
546+ expect ( again . getInstructions ( ) , "the new session's initialize carries the notice" ) . toBe (
547+ movedInstructions ( v2McpUrl ) ,
548+ ) ;
549+ // Past the re-read window, a session that already got the notice keeps
550+ // working: the client is not sent round again.
551+ yield * Effect . sleep ( Duration . millis ( E2E_MCP_V2_MOVED_RECHECK_MS * 2 ) ) ;
552+ expect ( yield * listToolsStatus ( again ) , "the new session keeps working" ) . toBe ( 200 ) ;
553+ expect ( yield * listToolsStatus ( again ) , "and keeps working" ) . toBe ( 200 ) ;
554+ } ) ,
555+ ) ;
556+
557+ scenario (
558+ "v2 moved · an agent connected before the move is told on its next request" ,
559+ { timeout : 180_000 } ,
560+ // Longer than the re-read window and shorter than the idle timeout, so the
561+ // session's runtime is still in memory and re-reads the move.
562+ connectedBeforeTheMove ( Duration . millis ( E2E_MCP_V2_MOVED_RECHECK_MS * 1.5 ) ) ,
563+ ) ;
564+
565+ scenario (
566+ "v2 moved · an agent connected before the move is told after its session is restored" ,
567+ { timeout : 180_000 } ,
568+ // Past the idle timeout, with room for the alarm: the session's runtime is
569+ // released, and the next request restores it without an `initialize`.
570+ connectedBeforeTheMove ( Duration . millis ( configuredMcpSessionTimeoutMs ( ) + 4_000 ) ) ,
571+ ) ;
572+
573+ // v2's slug rule allows 45 characters with no hyphen, which is one word the
574+ // browser cannot break by default.
575+ const UNBROKEN_SLUG = "syntheticorgslugwithnohyphensforlayoutchecks0" ;
576+
577+ scenario (
578+ "v2 moved · the longest unbroken v2 slug stays inside the notice and the Connect card on a phone" ,
579+ { timeout : 180_000 } ,
580+ Effect . gen ( function * ( ) {
581+ expect ( UNBROKEN_SLUG , "the longest slug v2 allows" ) . toHaveLength ( 45 ) ;
582+ const target = yield * Target ;
583+ const browser = yield * Browser ;
584+ const identity = yield * target . newIdentity ( ) ;
585+ const org = yield * orgOf ( target , identity ) ;
586+ yield * markMoved ( org . id , UNBROKEN_SLUG ) ;
587+
588+ yield * browser . session ( identity , async ( { page, step } ) => {
589+ // The narrowest common phone width.
590+ await page . setViewportSize ( { width : 320 , height : 640 } ) ;
591+
592+ await step ( "Open the moved org's dashboard on a phone-sized screen" , async ( ) => {
593+ await openDashboard ( page , org . slug ) ;
594+ await noticeOf ( page ) . waitFor ( ) ;
595+ } ) ;
596+
597+ await step ( "See the org to choose at sign-in wrap inside the notice" , async ( ) => {
598+ const notice = noticeOf ( page ) ;
599+ const hint = notice
600+ . getByRole ( "region" , { name : "v2 MCP server URL" } )
601+ . getByText (
602+ `Connect your agents to v2 instead, and choose ${ UNBROKEN_SLUG } when you sign in:` ,
603+ { exact : true } ,
604+ ) ;
605+ const dialog = await boxOf ( notice , "the notice" ) ;
606+ const text = await textExtent ( hint ) ;
607+ expect ( text . left , "the sentence starts inside the notice" ) . toBeGreaterThanOrEqual (
608+ dialog . left ,
609+ ) ;
610+ expect ( text . right , "the sentence ends inside the notice" ) . toBeLessThanOrEqual ( dialog . right ) ;
611+ } ) ;
612+
613+ await step ( "Close the notice and see the hint wrap inside the Connect card" , async ( ) => {
614+ await noticeOf ( page ) . getByRole ( "button" , { name : "Close" } ) . click ( ) ;
615+ await noticeOf ( page ) . waitFor ( { state : "hidden" } ) ;
616+ const next = page . getByRole ( "region" , { name : "New MCP server" } ) ;
617+ const card = page . locator ( '[data-slot="card-stack"]' ) . filter ( { has : next } ) ;
618+ const hint = next . getByText ( `Choose ${ UNBROKEN_SLUG } when you sign in.` , { exact : true } ) ;
619+ await hint . scrollIntoViewIfNeeded ( ) ;
620+ const cardBox = await boxOf ( card , "the Connect card" ) ;
621+ const text = await textExtent ( hint ) ;
622+ expect ( text . left , "the hint starts inside the card" ) . toBeGreaterThanOrEqual ( cardBox . left ) ;
623+ expect ( text . right , "the hint ends inside the card" ) . toBeLessThanOrEqual ( cardBox . right ) ;
624+ } ) ;
625+ } ) ;
626+ } ) ,
627+ ) ;
0 commit comments