diff --git a/dotnet/test/E2E/RpcSessionStateE2ETests.cs b/dotnet/test/E2E/RpcSessionStateE2ETests.cs index 53f3af7b8..56821e90f 100644 --- a/dotnet/test/E2E/RpcSessionStateE2ETests.cs +++ b/dotnet/test/E2E/RpcSessionStateE2ETests.cs @@ -276,14 +276,29 @@ public async Task Should_Fork_Session_With_Persisted_Messages() } [Fact] - public async Task Should_Report_Error_When_Forking_Session_Without_Persisted_Events() + public async Task Should_Handle_Forking_Session_Without_Persisted_Events() { await using var session = await CreateSessionAsync(); - var ex = await Assert.ThrowsAnyAsync(() => Client.Rpc.Sessions.ForkAsync(session.SessionId)); + SessionsForkResult? fork = null; + var ex = await Record.ExceptionAsync(async () => + { + fork = await Client.Rpc.Sessions.ForkAsync(session.SessionId); + }); - Assert.Contains("not found or has no persisted events", ex.ToString(), StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("Unhandled method sessions.fork", ex.ToString(), StringComparison.OrdinalIgnoreCase); + if (ex is not null) + { + Assert.Contains("not found or has no persisted events", ex.ToString(), StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Unhandled method sessions.fork", ex.ToString(), StringComparison.OrdinalIgnoreCase); + return; + } + + var forkSessionId = Assert.IsType(fork).SessionId; + Assert.False(string.IsNullOrWhiteSpace(forkSessionId)); + Assert.NotEqual(session.SessionId, forkSessionId); + + await using var forkedSession = await ResumeSessionAsync(forkSessionId); + Assert.Empty(GetConversationMessages(await forkedSession.GetMessagesAsync())); } [Fact] diff --git a/dotnet/test/GitHub.Copilot.SDK.Test.csproj b/dotnet/test/GitHub.Copilot.SDK.Test.csproj index e42dc8e4c..5d7e3dd16 100644 --- a/dotnet/test/GitHub.Copilot.SDK.Test.csproj +++ b/dotnet/test/GitHub.Copilot.SDK.Test.csproj @@ -2,6 +2,7 @@ false + true $(NoWarn);GHCP001 diff --git a/go/internal/e2e/rpc_session_state_e2e_test.go b/go/internal/e2e/rpc_session_state_e2e_test.go index e9cf1110f..623a26188 100644 --- a/go/internal/e2e/rpc_session_state_e2e_test.go +++ b/go/internal/e2e/rpc_session_state_e2e_test.go @@ -297,23 +297,50 @@ func TestRpcSessionStateE2E(t *testing.T) { forkedSession.Disconnect() }) - t.Run("should report error when forking session without persisted events", func(t *testing.T) { + t.Run("should handle forking session without persisted events", func(t *testing.T) { session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ OnPermissionRequest: copilot.PermissionHandler.ApproveAll, }) if err != nil { t.Fatalf("Failed to create session: %v", err) } + defer session.Disconnect() - _, err = client.RPC.Sessions.Fork(t.Context(), &rpc.SessionsForkRequest{SessionID: session.SessionID}) - if err == nil { - t.Fatal("Expected fork on empty session to fail") + fork, err := client.RPC.Sessions.Fork(t.Context(), &rpc.SessionsForkRequest{SessionID: session.SessionID}) + if err != nil { + errText := strings.ToLower(err.Error()) + if !strings.Contains(errText, "not found or has no persisted events") { + t.Errorf("Expected error mentioning 'not found or has no persisted events', got %v", err) + } + if strings.Contains(errText, "unhandled method sessions.fork") { + t.Errorf("sessions.fork should be implemented; error suggests it isn't: %v", err) + } + return } - if !strings.Contains(strings.ToLower(err.Error()), "not found or has no persisted events") { - t.Errorf("Expected error mentioning 'not found or has no persisted events', got %v", err) + if fork == nil { + t.Fatal("Expected non-nil fork result") } - if strings.Contains(strings.ToLower(err.Error()), "unhandled method sessions.fork") { - t.Errorf("sessions.fork should be implemented; error suggests it isn't: %v", err) + if strings.TrimSpace(fork.SessionID) == "" { + t.Fatal("Expected non-empty fork session id") + } + if fork.SessionID == session.SessionID { + t.Errorf("Expected fork session id to differ from source %q", session.SessionID) + } + + forkedSession, err := client.ResumeSession(t.Context(), fork.SessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to resume forked session: %v", err) + } + defer forkedSession.Disconnect() + + forkedMessages, err := forkedSession.GetMessages(t.Context()) + if err != nil { + t.Fatalf("Failed to read forked messages: %v", err) + } + if forkedConversation := conversationMessages(forkedMessages); len(forkedConversation) != 0 { + t.Errorf("Expected empty forked conversation, got %v", forkedConversation) } }) diff --git a/nodejs/test/e2e/rpc_session_state.e2e.test.ts b/nodejs/test/e2e/rpc_session_state.e2e.test.ts index 8adda8ab1..706c116e4 100644 --- a/nodejs/test/e2e/rpc_session_state.e2e.test.ts +++ b/nodejs/test/e2e/rpc_session_state.e2e.test.ts @@ -191,20 +191,34 @@ describe("Session-scoped RPC", async () => { await session.disconnect(); }); - it("should report error when forking session without persisted events", async () => { + it("should handle forking session without persisted events", async () => { const session = await client.createSession({ onPermissionRequest: approveAll }); - - await expect(client.rpc.sessions.fork({ sessionId: session.sessionId })).rejects.toSatisfy( - (err: unknown) => { + try { + let fork: Awaited>; + try { + fork = await client.rpc.sessions.fork({ sessionId: session.sessionId }); + } catch (err: unknown) { const text = err instanceof Error ? `${err.message}\n${err.stack ?? ""}` : String(err); expect(text.toLowerCase()).toContain("not found or has no persisted events"); expect(text.toLowerCase()).not.toContain("unhandled method sessions.fork"); - return true; + return; } - ); - await session.disconnect(); + expect(fork.sessionId.trim()).toBeTruthy(); + expect(fork.sessionId).not.toBe(session.sessionId); + + const forkedSession = await client.resumeSession(fork.sessionId, { + onPermissionRequest: approveAll, + }); + try { + expect(getConversationMessages(await forkedSession.getMessages())).toEqual([]); + } finally { + await forkedSession.disconnect(); + } + } finally { + await session.disconnect(); + } }); it("should fork session to event id excluding boundary event", async () => { diff --git a/python/e2e/test_rpc_session_state_e2e.py b/python/e2e/test_rpc_session_state_e2e.py index ffeec1cf3..0c841465a 100644 --- a/python/e2e/test_rpc_session_state_e2e.py +++ b/python/e2e/test_rpc_session_state_e2e.py @@ -216,20 +216,34 @@ async def test_should_fork_session_with_persisted_messages(self, ctx: E2ETestCon finally: await session.disconnect() - async def test_should_report_error_when_forking_session_without_persisted_events( + async def test_should_handle_forking_session_without_persisted_events( self, ctx: E2ETestContext ): session = await ctx.client.create_session( on_permission_request=PermissionHandler.approve_all, ) try: - with pytest.raises(Exception) as excinfo: - await ctx.client.rpc.sessions.fork( + try: + fork = await ctx.client.rpc.sessions.fork( SessionsForkRequest(session_id=session.session_id) ) - text = str(excinfo.value).lower() - assert "not found or has no persisted events" in text - assert "unhandled method sessions.fork" not in text + except Exception as exc: + text = str(exc).lower() + assert "not found or has no persisted events" in text + assert "unhandled method sessions.fork" not in text + return + + assert fork.session_id.strip() + assert fork.session_id != session.session_id + + forked_session = await ctx.client.resume_session( + fork.session_id, + on_permission_request=PermissionHandler.approve_all, + ) + try: + assert _conversation_messages(await forked_session.get_messages()) == [] + finally: + await forked_session.disconnect() finally: await session.disconnect()