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
6 changes: 3 additions & 3 deletions dotnet/src/Generated/Rpc.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

95 changes: 70 additions & 25 deletions dotnet/test/E2E/PendingWorkResumeE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,18 @@ public class PendingWorkResumeE2ETests(E2ETestFixture fixture, ITestOutputHelper
private static readonly TimeSpan PendingWorkTimeout = TimeSpan.FromSeconds(60);
private const string SharedToken = "pending-work-resume-shared-token";

[Fact]
// Skipped after the runtime 1.0.56 bump. Runtime PR #9040 (commit b8e1220b45) changed
// SDKServer.handleConnectionClosed to tear down the session when the last RPC client
// disconnects, so the in-memory pending permission request is gone before the resumed
// client can satisfy it and HandlePendingPermissionRequest returns success=false. This
// test models same-process ForceStop+resume; it needs to be redesigned to either keep
// an owner connected (warm resume) or to model a true process restart against the
// persisted session state.
[Fact(Skip = "Runtime 1.0.56 cleans up the session on last-client disconnect (copilot-agent-runtime PR #9040), so the in-memory pending request is gone before resume can satisfy it. Test needs redesign.")]
public async Task Should_Continue_Pending_Permission_Request_After_Resume()
{
var originalPermissionRequest = new TaskCompletionSource<PermissionRequest>(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseOriginalPermission = new TaskCompletionSource<PermissionDecision>(TaskCreationOptions.RunContinuationsAsynchronously);
var resumedToolInvoked = false;

await using var server = Ctx.CreateClient(options: new CopilotClientOptions { Connection = RuntimeConnection.ForTcp(connectionToken: SharedToken) });
await server.StartAsync();
Expand Down Expand Up @@ -66,10 +72,7 @@ await session1.SendAsync(new MessageOptions
[
AIFunctionFactory.Create(
([Description("Value to transform")] string value) =>
{
resumedToolInvoked = true;
return $"PERMISSION_RESUMED_{value.ToUpperInvariant()}";
},
$"PERMISSION_RESUMED_{value.ToUpperInvariant()}",
"resume_permission_tool")
],
});
Expand All @@ -79,11 +82,6 @@ await session1.SendAsync(new MessageOptions
new RpcPermissionDecisionApproveOnce());
Assert.True(permissionResult.Success);

var answer = await TestHelper.GetFinalAssistantMessageAsync(session2, PendingWorkTimeout);

Assert.True(resumedToolInvoked);
Assert.Contains("PERMISSION_RESUMED_ALPHA", answer?.Data.Content ?? string.Empty);

await session2.DisposeAsync();
await resumedTcpClient.ForceStopAsync();
}
Expand All @@ -97,7 +95,12 @@ static string ResumePermissionTool([Description("Value to transform")] string va
$"ORIGINAL_SHOULD_NOT_RUN_{value}";
}

[Fact]
// Skipped for the same reason as Should_Continue_Pending_Permission_Request_After_Resume:
// runtime 1.0.56 (copilot-agent-runtime PR #9040) tears down the session when the last
// RPC client disconnects, so the in-memory pending external tool call is gone before
// the resumed client can satisfy it. Needs redesign to keep an owner connected (warm)
// or to model true process-restart resume from persisted state.
[Fact(Skip = "Runtime 1.0.56 cleans up the session on last-client disconnect (copilot-agent-runtime PR #9040), so the in-memory pending tool call is gone before resume can satisfy it. Test needs redesign.")]
public async Task Should_Continue_Pending_External_Tool_Request_After_Resume()
{
var originalToolStarted = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
Expand Down Expand Up @@ -140,10 +143,6 @@ await session1.SendAsync(new MessageOptions
result: JsonDocument.Parse("\"EXTERNAL_RESUMED_BETA\"").RootElement.Clone());
Assert.True(toolResult.Success);

var answer = await TestHelper.GetFinalAssistantMessageAsync(session2, PendingWorkTimeout);

Assert.Contains("EXTERNAL_RESUMED_BETA", answer?.Data.Content ?? string.Empty);

await session2.DisposeAsync();
await resumedClient.ForceStopAsync();
}
Expand All @@ -161,7 +160,23 @@ async Task<string> BlockingExternalTool([Description("Value to look up")] string
}

[Fact]
public async Task Should_Keep_Pending_External_Tool_Handleable_On_Warm_Resume_When_ContinuePendingWork_Is_False()
public Task Should_Keep_Pending_External_Tool_Handleable_On_Warm_Resume_When_ContinuePendingWork_Is_False() =>
AssertPendingExternalToolHandleableOnResumeAsync(
disconnectOriginalClient: false,
expectedSessionWasActive: true,
expectedHandleResult: true);

[Fact]
public Task Should_Keep_Pending_External_Tool_Handleable_On_Cold_Resume_When_ContinuePendingWork_Is_False() =>
AssertPendingExternalToolHandleableOnResumeAsync(
disconnectOriginalClient: true,
expectedSessionWasActive: false,
expectedHandleResult: false);

private async Task AssertPendingExternalToolHandleableOnResumeAsync(
bool disconnectOriginalClient,
bool expectedSessionWasActive,
bool expectedHandleResult)
{
var originalToolStarted = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseOriginalTool = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
Expand Down Expand Up @@ -191,28 +206,54 @@ await session1.SendAsync(new MessageOptions
var toolEvent = await toolRequested;
Assert.Equal("beta", await originalToolStarted.Task.WaitAsync(PendingWorkTimeout));

await suspendedClient.ForceStopAsync();
if (disconnectOriginalClient)
{
await suspendedClient.ForceStopAsync();
}

await using var resumedClient = Ctx.CreateClient(options: new CopilotClientOptions { Connection = RuntimeConnection.ForUri(cliUrl, connectionToken: SharedToken) });
var session2 = await resumedClient.ResumeSessionAsync(sessionId, new ResumeSessionConfig

// In warm mode the original client still owns the tool registration;
// re-registering it from the resumed client would cause a name-clash. In
// cold mode the original is gone, so we register a fresh throwing handler
// to assert the runtime doesn't re-invoke the tool on resume (orphan
// auto-completion happens internally).
var resumeConfig = new ResumeSessionConfig
{
ContinuePendingWork = false,
OnPermissionRequest = PermissionHandler.ApproveAll,
});
};
if (disconnectOriginalClient)
{
resumeConfig.Tools = [AIFunctionFactory.Create(ResumedExternalTool, "resume_external_tool")];
}

var session2 = await resumedClient.ResumeSessionAsync(sessionId, resumeConfig);

var resumeEvent = await GetSingleResumeEventAsync(session2);
Assert.Equal(false, resumeEvent.Data.ContinuePendingWork);
Assert.Equal(true, resumeEvent.Data.SessionWasActive);
Assert.Equal(expectedSessionWasActive, resumeEvent.Data.SessionWasActive);

// Warm: the runtime still has the pending request and HandlePendingToolCall
// will succeed.
// Cold: the runtime auto-completed the orphaned tool call with a synthetic
// interrupt result during resume, so HandlePendingToolCall correctly reports
// success=false. The session should still be healthy for new turns.
var resumedResult = await session2.Rpc.Tools.HandlePendingToolCallAsync(
toolEvent.Data.RequestId,
result: JsonDocument.Parse("\"EXTERNAL_RESUMED_BETA\"").RootElement.Clone());
Assert.True(resumedResult.Success);

// continuePendingWork=false may interrupt agent continuation before this response,
// but the pending call should still accept an explicit completion.
Assert.Equal(expectedHandleResult, resumedResult.Success);
Assert.Equal(1, invocationCount);

if (!expectedHandleResult)
{
var followUp = await session2.SendAndWaitAsync(new MessageOptions
{
Prompt = "Reply with exactly: COLD_RESUMED_FOLLOWUP",
});
Assert.Contains("COLD_RESUMED_FOLLOWUP", followUp?.Data.Content ?? string.Empty);
}

await session2.DisposeAsync();
await resumedClient.ForceStopAsync();
}
Expand All @@ -228,6 +269,10 @@ async Task<string> BlockingExternalTool([Description("Value to look up")] string
originalToolStarted.TrySetResult(value);
return await releaseOriginalTool.Task;
}

[Description("Looks up a value after resumption")]
string ResumedExternalTool([Description("Value to look up")] string value) =>
throw new InvalidOperationException("Resumed-session handler should not be invoked");
}

[Fact]
Expand Down
Loading
Loading