Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions src/Repl.Core/CoreReplApp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,7 @@ public ValueTask<int> RunAsync(string[] args, CancellationToken cancellationToke
_ = _middleware.Count;
_ = _options;
cancellationToken.ThrowIfCancellationRequested();
return ExecuteCoreAsync(args, _services, cancellationToken);
return ExecuteCoreAsync(args, _services, cancellationToken: cancellationToken);
}

internal RouteMatch? Resolve(IReadOnlyList<string> inputTokens)
Expand All @@ -408,19 +408,34 @@ internal ValueTask<int> RunWithServicesAsync(
string[] args,
IServiceProvider serviceProvider,
CancellationToken cancellationToken = default) =>
ExecuteCoreAsync(args, serviceProvider, cancellationToken);
ExecuteCoreAsync(args, serviceProvider, cancellationToken: cancellationToken);

/// <summary>
/// Executes a nested command invocation that preserves the session baseline.
/// Used by MCP tool calls where the global options from the initial session
/// must remain in effect even though the sub-invocation tokens don't contain them.
/// </summary>
internal ValueTask<int> RunSubInvocationAsync(
string[] args,
IServiceProvider serviceProvider,
CancellationToken cancellationToken = default) =>
ExecuteCoreAsync(args, serviceProvider, isSubInvocation: true, cancellationToken);

private async ValueTask<int> ExecuteCoreAsync(
IReadOnlyList<string> args,
IServiceProvider serviceProvider,
CancellationToken cancellationToken)
bool isSubInvocation = false,
CancellationToken cancellationToken = default)
{
_options.Interaction.SetObserver(observer: ExecutionObserver);
try
{
var globalOptions = GlobalOptionParser.Parse(args, _options.Output, _options.Parsing);
_globalOptionsSnapshot.Update(globalOptions.CustomGlobalNamedOptions);
_globalOptionsSnapshot.SetSessionBaseline();
_globalOptionsSnapshot.Update(globalOptions.CustomGlobalNamedOptions); // volatile ref swap — safe under concurrent sub-invocations
if (!isSubInvocation)
{
_globalOptionsSnapshot.SetSessionBaseline();
}
using var runtimeStateScope = PushRuntimeState(serviceProvider, isInteractiveSession: false);
var prefixResolution = ResolveUniquePrefixes(globalOptions.RemainingTokens);
var resolvedGlobalOptions = globalOptions with { RemainingTokens = prefixResolution.Tokens };
Expand Down
52 changes: 52 additions & 0 deletions src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,58 @@ public void When_PropertyHasConsecutiveUppercase_Then_KebabCaseIsCorrect()
output.Text.Should().Contain("9090");
}

[TestMethod]
[Description("Sub-invocation via RunSubInvocationAsync preserves baseline from top-level Run.")]
public async Task When_SubInvocationAfterRun_Then_BaselineGlobalOptionsArePreserved()
{
string? capturedTenant = null;
var sut = ReplApp.Create();
sut.UseGlobalOptions<TestGlobalOptions>();
sut.Map("show", (TestGlobalOptions opts) => $"{opts.Tenant}:{opts.Port}");
sut.Map("check", (IGlobalOptionsAccessor globals) =>
{
capturedTenant = globals.GetValue<string>("tenant");
return "ok";
});

// Top-level Run establishes baseline with --tenant acme.
ConsoleCaptureHelper.Capture(
() => sut.Run(["show", "--tenant", "acme", "--no-logo"]));

// Sub-invocation without --tenant must still see the baseline value.
await sut.Core.RunSubInvocationAsync(
["--no-logo", "check"], sut.Services).ConfigureAwait(false);

capturedTenant.Should().Be("acme");
}

[TestMethod]
[Description("Sub-invocation does not reset baseline for subsequent sub-invocations.")]
public async Task When_MultipleSubInvocations_Then_BaselineRemainsStable()
{
var captured = new List<string?>();
var sut = ReplApp.Create();
sut.UseGlobalOptions<TestGlobalOptions>();
sut.Map("show", (TestGlobalOptions opts) => "ok");
sut.Map("check", (IGlobalOptionsAccessor globals) =>
{
captured.Add(globals.GetValue<string>("tenant"));
return "ok";
});

// Top-level Run establishes baseline.
ConsoleCaptureHelper.Capture(
() => sut.Run(["show", "--tenant", "acme", "--no-logo"]));

// Two consecutive sub-invocations — baseline must survive both.
await sut.Core.RunSubInvocationAsync(
["--no-logo", "check"], sut.Services).ConfigureAwait(false);
await sut.Core.RunSubInvocationAsync(
["--no-logo", "check"], sut.Services).ConfigureAwait(false);

captured.Should().AllBe("acme");
}

private sealed class DecimalGlobalOptions
{
public double Rate { get; set; }
Expand Down
2 changes: 1 addition & 1 deletion src/Repl.Mcp/McpToolAdapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ private async Task<CallToolResult> ExecuteThroughPipelineAsync(
isHostedSession: true))
{
ReplSessionIO.IsProgrammatic = true;
var exitCode = await coreApp.RunWithServicesAsync(
var exitCode = await coreApp.RunSubInvocationAsync(
effectiveTokens.ToArray(), mcpServices, ct).ConfigureAwait(false);

var output = outputWriter.ToString().Trim();
Expand Down
Loading