Skip to content

Add the F# Interactive window on the JSON-RPC protocol - #20565

Open
xperiandri wants to merge 25 commits into
dotnet:mainfrom
xperiandri:modern-repl
Open

xperiandri wants to merge 25 commits into
dotnet:mainfrom
xperiandri:modern-repl

Conversation

@xperiandri

@xperiandri xperiandri commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Replaces the Visual Studio F# Interactive window with one hosted on Microsoft.VisualStudio.InteractiveWindow — the same REPL engine C# Interactive and Python Interactive use — driving fsi over the JSON-RPC protocol fsi gained in #20396 instead of parsing SERVER-PROMPT> text on stdin.

image

@github-actions

github-actions Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

✅ Release notes checked


✅ Found changes and release notes in following paths:

Change path Release notes path Description
`vsintegration/src` docs/release-notes/.VisualStudio/18.vNext.md


<ItemGroup>
<ProjectReference Include="$(FSharpSourcesRoot)\FSharp.Core\FSharp.Core.fsproj" />
<ProjectReference Include="$(FSharpSourcesRoot)\Compiler\FSharp.Compiler.Service.fsproj" />

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How would the architecture need to change for the window to locate the SDK, and start the JSON-RPC server from the SDK? i.e. the Window NOT having a reference to FSharp.Compiler.Service.fsproj

That way, we decouple VS versions and SDK versions (steered via global.json).
We would also gain parity of CLI fsi, CLI build VS FSI - guaranteed to use same compiler bits.

And more importantly, the VS FSI could then be powered by net11 F# compiler, benefiting from any perf improvements on the .NET side.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in the last commit: the session starts in the solution folder with the dotnet a shell there would run, so the host resolves the SDK from global.json exactly as dotnet fsi does — nothing in the window re-implements that, and the fsi it evaluates on is the SDK's. The FCS reference that remains serves the tokenizer only (Enter-vs-newline in SubmissionAnalysis, lexical colouring) — the same FCS FSharp.Editor already loads; it plays no part in evaluation.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖🕵️ @xperiandri This is still occurring.

<ProjectReference Include="$(FSharpSourcesRoot)\Compiler\FSharp.Compiler.Service.fsproj" />

SDK selection is fixed; the requested no-FCS window boundary is not.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, and my earlier answer only covered evaluation. The window no longer references FSharp.Compiler.Service.fsproj, and cannot compile against it: DisableTransitiveProjectReferences stops FSharp.VS.FSI (which keeps the compiler for the old window) from handing it on. What the window used the compiler for was its lexer, to colour input and output and to decide when Enter submits. That is now ILexicalScannerFactory in LexicalScanning.fs, and FSharp.Editor, which already runs on the compiler Visual Studio ships, exports the implementation. The session's own compiler stays the SDK's, in the dotnet fsi process. The submission rule is still tested, with the editor's scanner, in SubmissionAnalysisTests.

UICultureLcid: int
}

module internal FsiLocator =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should use the SDK locator as if you launched dotnet fsi from the solution folder (i.e. following global.json).

Also as long as we keep the legacy FSI in current shape, the new window via JSON-RPC could be netcoreapp only and not offer any of the .NET Framework options.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both done: the platform choice, the desktop executables and the shadow-copy switch are gone from this window (they stay with the old one), and the session starts in the solution folder so global.json picks the SDK. When a session comes up the window prints which fsi answered, on what runtime and where.

@xperiandri

Copy link
Copy Markdown
Contributor Author

Re-stacked on xperiandri/fsharp@c5501123 (#20396 after its second pass: the server is .NET-only, the pipe is CurrentUserOnly, and EvalInteraction evaluates a whole selection, so the window no longer relies on the server splitting on ;;). The only change here is that the window passes --fsi-server-client-pid, so a Visual Studio that dies between launching fsi and connecting no longer leaves a session waiting on the pipe.

Two things from the review are still open and are the next steps for this PR rather than for #20396:

  1. Locating fsi through the SDK that global.json resolves to, instead of FSHARP_INTERACTIVE_PATH or dotnet from PATH@T-Gro's thread on InteractiveHost.fs.
  2. Dropping the .NET Framework platform choices from this window. The server now exists only in the .NET fsi, so picking a desktop platform ends with fsi's own "The JSON-RPC server mode is not available in this build of F# Interactive." on the error stream.

One limit worth knowing: the client side cannot ask for PipeOptions.CurrentUserOnly on net472, so the GUID pipe name is what stands between the window and a pipe squatter until the window itself targets .NET.

@xperiandri
xperiandri force-pushed the modern-repl branch 5 times, most recently from 1db5c63 to 0e32fd1 Compare September 21, 2026 12:14
@T-Gro

T-Gro commented Sep 21, 2026

Copy link
Copy Markdown
Member

@xperiandri Current head 0e32fd1 is conflicting. Build MacOS Batch2, Build CheckCodeFormatting, fsharp-ci, and check_release_notes are failing.

@T-Gro T-Gro left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖🕵️ Review follow-up.

addSwitch $"{CommandLine.ServerOption}{pipeName}"
// How the host names itself: a session whose host dies, even before the handshake, exits instead of
// waiting on the pipe forever.
addSwitch $"--fsi-server-client-pid:{clientProcessId}"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖🕵️ @xperiandri user arguments can override the trusted owner PID; closing VS leaves FSI alive.

trusted:       --fsi-server-client-pid:<VS pid>
UserArguments: --fsi-server-client-pid:0
owner exits -> server_alive=true
// Reject duplicate ownership switches, or append the trusted PID after UserArguments.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The window's own switches now come last, after the user's arguments, and for --fsi-server-jsonrpc and --fsi-server-client-pid the last occurrence wins, so --fsi-server-client-pid:0 in the arguments no longer names the owner. the last owner named on the command line is the one that counts starts a session with two owners and closes the first; the session keeps serving.

@xperiandri
xperiandri force-pushed the modern-repl branch 2 times, most recently from 1ca49bc to 7609924 Compare September 21, 2026 16:41
xperiandri and others added 13 commits September 21, 2026 23:03
Builds the Visual Studio side of the replacement for the legacy F# Interactive
window against Microsoft.VisualStudio.InteractiveWindow, the REPL engine C#
Interactive and Python Interactive already run on.

InteractiveHost owns the fsi process and speaks the protocol to it. Because the
handshake reports the process that actually evaluates code, the temporary-file
dance the old window used to discover it under "dotnet fsi" is gone, and so is
the line counting that hid the output that discovery produced.

FSharpInteractiveEvaluator implements IInteractiveEvaluator. Diagnostics arrive
as data and are rendered in the compiler's own layout. AbortExecution maps to
the protocol's interrupt, which the session serves as it arrives; the equivalent
in C# Interactive is still an empty method.

SubmissionAnalysis decides whether Enter submits or adds a line. The window asks
on every keystroke on the UI thread, so the judgement is lexical: an explicit
';;' always submits, and without one the submission goes when nothing is left
visibly open. Brackets and terminators inside strings and comments do not count.

FSharpVsInteractiveWindowProvider creates the tool window and gives its input
buffer the F# content type and language service, so the ordinary editor features
apply to what the user types. Session settings come from the SessionsProperties
the existing Tools, Options page already writes, so no second options page is
needed. FSharpVsInteractiveWindowPackage registers the window so Visual Studio
can restore it from a persisted layout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Its pkgdef, its MEF component, and the Interactive Window prerequisite that
supplies the REPL engine the window runs on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"Send to Interactive" (Alt+Enter), "Send line" (Alt+') and the "F# Interactive"
command now open and drive the window built on the interactive window package,
not the legacy tool window.

The command filter moves to the window's own project, where Roslyn also keeps
this code, and reads the submission from the text view rather than through DTE:
the selection, or the caret's line when there is none, in which case the caret
advances so that repeated Alt+Enter walks down a script.

Text sent from an editor carries its file and line to the session, so its
diagnostics land on the user's own source. The interactive window submits text
without saying where it came from, so the evaluator takes the origin from the
command that is about to submit.

The legacy filter keeps only "Debug in Interactive", which has not been ported
yet, and the legacy window is no longer reachable from any command.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The window launched "dotnet fsi", which resolves to whatever the installed SDK
ships. That fsi does not know --fsi-server-jsonrpc, so every session died with
FS0243 before the handshake. The extension carries no fsi of its own to fall
back on: the VSIX ships none, and never has.

FSHARP_INTERACTIVE_PATH now names the fsi to run. Given a .dll it runs under a
.NET host found beside it - a build of fsi from a repository needs the runtime
that repository provisions, which is usually newer than any machine-wide one -
and given an executable it runs that directly.

A session that exits before the handshake now says which executable exited and
points at the variable, instead of leaving the compiler's FS0243 as the only
clue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The token sets become ordinal HashSets, the terminator check an explicit
ordinal String.Equals, and the tests say Ordinal where they assert on prefixes,
suffixes, containment and severity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ce guid

The F# language service guid was written out here for the fifth time in this
repository. FSharp.VS.FSI already declares it and this project references that,
so it is used from there instead.

The identities module becomes public so that it is the one place to reference
rather than a copy every component keeps. Roslyn publishes no equivalent: its
Guids expose only the F# package id, which is what it uses to force the package
to load.

The content type name stays a literal here. FSharp.Editor declares it too, but
it now references this project, so the name cannot be shared in that direction.

Also finishes the voption conversion of the session state, which the build was
waiting on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Converted where the option is ours: the submission scan and its last-token and
colour state, the editor submission the command filter reads, the escaping
exception in an execution result, and the pipe name the entry point looks for.

Left as Option where the language or an API requires it: F# optional parameters,
Array.tryPick, and the FCS tokenizer, whose result is converted at the boundary
with ValueOption.ofOption instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The escaping exception in an execution result, the platform name parser, and the
two harness readers the tests match on. The previous commit claimed the first of
these but carried only the pipe name.

Reflowed the result description so the two interpolated pieces split on a word
boundary rather than mid-sentence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The window was written with `| null` annotations but never turned the checking
on, so they were decoration. Enabling it named four wire fields that really do
arrive null, and found the places that read a nullable value without narrowing
it first.

The protocol records now say which fields are optional over the wire. fsi
compiles the same file with checking off, where the annotations are simply
carried.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`isNull` answers the question without narrowing the type, so the value stays
nullable for the rest of the expression and every use after it has to be
defended separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The other half of the pair: this filter forwards the commands the interactive
one does not claim, and was left on isNull when that one moved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
xperiandri and others added 12 commits September 21, 2026 23:03
The platform name now dispatches on the name itself rather than through a
comparison helper called four times, the submission rule reads the scan it
already produced as a record pattern, and the two guards before an interaction
runs are stated as the pair of conditions they are.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Neither a shell command group nor set membership can be written as a pattern, so
the remaining ladders became active patterns that classify once and give the
cases names. The command one also drops the group check that both Exec branches
were repeating.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fsi already named it, but in a module only fsi compiles, so the window and the
test harness spelled the switch out again. It moves next to the method names,
which both ends already take from the shared file for the same reason.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rewriting the ladder as a match traded the comparison for a ToLowerInvariant,
which is a different rule and not one that was asked for. The ladder is the
lesser cost.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The debug profile already redirects the compiler to the artifacts build; the
interactive window needs the same treatment, because the fsi an installed SDK
resolves to does not know the JSON-RPC option yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The input buffer belongs to no project, so the editor's semantic classification
never sees it, and the window was as plain as the legacy one. A tokenizer-based
classifier fills the gap until Phase 3 makes submissions workspace documents.

It serves only buffers carrying the interactive window property, which the
window sets on each submission buffer it creates; a buffer belonging to a
document keeps its project-driven colour. Confirmed against the package binary:
AddLanguageBuffer stores the window on every submission buffer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The value printer answers in F# signature syntax, so the same tokenizer serves.
Output differs from input in shape rather than language: it grows for the life
of the session and interleaves printed values with console writes, so each line
is coloured on its own and an edit invalidates only the lines it touched.

Both interactive content types are shared with every language the window
package hosts, so the classifier now claims a buffer only when the window it
belongs to evaluates F#.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The window package records itself in the properties of the buffers it creates
for input but not for output, so asking a buffer which window it belongs to
could never answer for output. It also replaces the output buffer on reset,
which rules out stamping one. The windows evaluating F# now keep a register, and
an output buffer is ours when one of them currently owns it.

Ownership is also settled later than before. The buffer reaches the provider
before the window has finished claiming it, so deciding at that moment decided
once and for all against us; it is now asked on each request until known.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The session used to learn its host from the handshake alone, so a Visual Studio that died between launching fsi and connecting left it waiting on the pipe for good. `--fsi-server-client-pid` names the process before the pipe opens; the handshake still carries it for a session started some other way.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The window picked its own fsi: `dotnet.exe` from Program Files, or one of the desktop executables the extension ships, chosen through the options page the old window reads. The desktop ones cannot serve the protocol at all — the server exists in the .NET fsi alone — and the machine-wide `dotnet` ignored the SDK a solution pins.

The session now starts in the open solution's folder with the `dotnet` a shell there would run, so the host resolves the SDK from that folder's `global.json` exactly as `dotnet fsi` typed at a prompt does, and the window runs the same compiler bits as `dotnet build`. The platform choice, the desktop executables and the shadow-copy switch stay with the old window. When a session comes up the window prints which fsi answered, on what runtime and where, so a pinned SDK is seen rather than guessed at.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
For --fsi-server-jsonrpc and --fsi-server-client-pid the last occurrence wins, so the user's own arguments could name another pipe or another process as the session's owner, and the session would outlive the window. The window's switches now come last; a test pins the last-wins rule they rely on.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…the F# compiler

The window used the compiler for one thing, its lexer, to colour input and output and to decide when Enter submits. It now asks an ILexicalScannerFactory, which FSharp.Editor, already running on the compiler Visual Studio ships, exports. The window drops its reference to FSharp.Compiler.Service, and DisableTransitiveProjectReferences keeps FSharp.VS.FSI from handing it back; the session's own compiler remains the SDK's, in the dotnet fsi process. The submission rule is tested with the editor's scanner.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@xperiandri
xperiandri marked this pull request as ready for review September 21, 2026 21:03
@xperiandri
xperiandri requested a review from a team as a code owner September 21, 2026 21:03
@github-actions github-actions Bot added ⚠️ Affects-Design-Time Tooling check: PR touches type providers or dependency manager ⚠️ Affects-Test-Tooling Tooling check: PR touches test framework infrastructure ⚠️ Affects-Agent-Config Tooling check: PR modifies AI agent instructions or workflows ⚠️ Affects-Build-Infra Tooling check: PR touches build infrastructure ⚠️ Affects-Restore Tooling check: PR touches NuGet packages or feeds labels Sep 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🔍 Tooling Safety Check — Affects-Agent-Config, Affects-Build-Infra, Affects-Design-Time, Affects-Restore, Affects-Test-Tooling
Affects-Agent-Config: Adds a Claude rule controlling repository agent behavior.
Affects-Build-Infra: Changes solution, MSBuild properties, targets, and project structure.
Affects-Design-Time: Adds Visual Studio package code loaded during IDE use.
Affects-Restore: Adds package versions and package references.
Affects-Test-Tooling: Changes FSI process harness and test project compilation.

Generated by PR Tooling Safety Check · gpt56 604.8K ·

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

⚠️ Affects-Agent-Config Tooling check: PR modifies AI agent instructions or workflows ⚠️ Affects-Build-Infra Tooling check: PR touches build infrastructure ⚠️ Affects-Design-Time Tooling check: PR touches type providers or dependency manager ⚠️ Affects-Restore Tooling check: PR touches NuGet packages or feeds ⚠️ Affects-Test-Tooling Tooling check: PR touches test framework infrastructure

Projects

Status: New

Development

Successfully merging this pull request may close these issues.

2 participants